diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..e91dd6aa --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,252 @@ +{ + "모달 닫기": "Close modal", + "유효하지 않은 투표 링크입니다.": "Invalid vote link.", + "승패투표": "Win/Loss Vote", + "참여자 :": "Participants:", + "찬성팀": "Affirmative Team", + "반대팀": "Negative Team", + "투표완료": "Submit vote", + "다시 투표하기": "Vote again", + "제출하기": "Submit", + "투표를 제출하시겠습니까?": "Submit your vote?", + "(제출 후에는 변경이 불가능 합니다.)": "(You cannot change it after submitting.)", + "투표가 완료되었습니다.": "Your vote has been submitted.", + "테이블 이름 없음": "Unnamed table", + "주제 없음": "No Topic", + "도움말": "Help", + "전체 화면": "Fullscreen", + "테이블 ID가 올바르지 않습니다.": "Invalid table ID.", + "공유받은 테이블을 저장하지 못했어요.": "Couldn't save the shared table.", + "테이블 데이터를 확인할 수 없어요.": "Can't find the table data.", + "공유된 데이터가 비어 있어요.": "Shared data is empty.", + "공유된 토론 테이블을 DB에 저장하지 못했어요.": "Couldn't save the shared debate table to the database.", + "데이터를 처리하고 있습니다...": "Processing data...", + "공유받은 데이터 처리에 실패했어요.": "Failed to process the shared data.", + "팀 선정하기": "Select teams", + "토론하기": "Start debate", + "토론 시간표를 선택해주세요": "Please select a debate schedule", + "테이블 모드가 올바르지 않습니다.": "Invalid table mode.", + "시간표 1": "Schedule 1", + "찬성": "Affirmative", + "반대": "Negative", + "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the schedule.\nTry again?", + "무승부": "Tie", + "유효하지 않은 투표 결과 링크입니다.": "Invalid vote result link.", + "세부 결과 확인하기": "View detailed results", + "아니오": "No", + "네": "Yes", + "정말로 세부 결과를 공개할까요?": "Show detailed results?", + "토론을 모두 마치셨습니다": "You have finished the debate", + "박수": "Applause", + "피드백 타이머": "Feedback Timer", + "심사평 및 Q&A용 타이머 →": "Timer for feedback & Q&A →", + "피드백 타이머로 이동": "Go to Feedback Timer", + "승패투표 진행하기": "Start Win/Loss Vote", + "QR 코드를 통해 투표 페이지로 이동해요.": "Go to the voting page via QR code.", + "승패투표 생성 및 진행": "Create and run Win/Loss Vote", + "스캔해 주세요!": "Please scan!", + "참여자": "Participants", + "등록된 토론자가 없어요.": "No debaters registered.", + "투표 결과 보기": "View vote results", + "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?": "Couldn't load the QR code.\nTry again?", + "링크가 클립보드에 복사됨": "Link copied to clipboard", + "링크 준비 중": "Preparing link", + "공유 링크 복사": "Copy share link", + "이전 차례": "Previous turn", + "다음 차례": "Next turn", + "토론 종료": "End debate", + "알림 개수_one": "{{displayCount}} notification", + "알림 개수_other": "{{displayCount}} notifications", + "{{team}} 팀": "{{team}} team", + "데이터를 불러오고 있습니다...": "Loading data...", + "토론 종료 화면으로 돌아가기": "Back to debate end screen", + "데이터를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the data.\nTry again?", + "다시 시도하기": "Try again", + "페이지를 찾을 수 없어요...": "Page not found...", + "요청 URL": "Requested URL", + "오류 내용": "Error details", + "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.": "Couldn't find the page you requested.\nPlease return to Home and try again.", + "홈으로 돌아가기": "Go to Home", + "{{status}} 오류": "{{status}} Error", + "오류가 발생했어요...": "Something went wrong...", + "스택": "Stack", + "선택": "Select", + "타이머 초기화": "Reset timer", + "일시정지": "Pause", + "재생": "Play", + "A키": "A key", + "L키": "L key", + "전체 시간": "Total time", + "현재 시간": "Current time", + "팀": "Team", + "토론자": "Debater", + "작전 시간 사용": "Use Prep Time", + "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?": "You've finished the debate!\nLog in to save your schedule so far?", + "자유토론 타이머 조작": "Open Debate Timer Controls", + "재생 버튼을 눌러 타이머를 시작": "Press Play to start the timer", + "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지": "When the timer is running, press Pause to pause it", + "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화": "Press Reset to restore the timer to its original time", + "마우스를 사용하여 타이머를 클릭 시, 진영 변경": "Click the timer with the mouse to switch Stance", + "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작": "When Stance changes while running, switch to the other side's timer and start immediately", + "일반 토론 타이머 조작": "Standard Debate Timer Controls", + "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능": "Press Use Prep Time to use a separate Prep Time timer", + "키보드 조작": "Keyboard controls", + "스페이스 바로 타이머를 시작 및 일시정지": "Use the Spacebar to start/pause the timer", + "R 키로 타이머 초기화": "Use R to reset the timer", + "좌우 방향키로 이전/다음 차례로 이동": "Use Left/Right arrows to go to the previous/next turn", + "A/L 키로 토론 진영 변경": "Use A/L to switch Stance", + "Enter 키로 상대 진영으로 변경": "Use Enter to switch to the opposing Stance", + "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화": "Activate fullscreen with the header button <0/> in the top right", + "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화": "Exit fullscreen with the header close button <0/> or press ESC", + "닫기": "Close", + "작전 시간": "Prep Time", + "-1분": "-1 min", + "-30초": "-30 sec", + "+30초": "+30 sec", + "+1분": "+1 min", + "-5분": "-5 min", + "+5분": "+5 min", + "비회원 상태로 토론하기": "Debate as guest", + "저장하기": "Save", + "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?": "Save the shared debate schedule to my schedules?", + "수정하기": "Edit", + "삭제하기": "Delete", + "공유하기": "Share", + "주제 | ": "Topic | ", + "취소": "Cancel", + "삭제": "Delete", + "테이블을 삭제하시겠습니까?": "Delete this table?", + "타이머 화면": "Timer screen", + "원하는 때에\n작전 시간 사용하기": "Use Prep Time\nwhenever you need it", + "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요": "If a debater requests Prep Time\npress <0/>\nto use time", + "작전 시간이 나타나면\n원하는 시간을 입력하세요!": "When Prep Time appears,\nenter the time you want!", + "키보드 방향키로\n더 편리한 조작": "More convenient control\nwith arrow keys", + "시간표 설정화면": "Schedule setup screen", + "간편한 시간표 구성": "Simple schedule setup", + "시간표 추가": "Add schedule", + "시간표 추가 버튼": "Add schedule button", + "두가지 타이머": "Two timers", + "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.": "Support various debate formats\nwith Standard and Open Debate timers.", + "종소리 설정": "Bell settings", + "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.": "Customize the bell by time\nhowever you like.", + "다양한 토론 템플릿을 원클릭으로 만나보세요!": "Explore various debate templates with one click!", + "{{title}} 로고": "{{title}} logo", + "{{label}} 토론하기": "Debate {{label}}", + "템플릿 신청하기": "Request a template", + "새로운 템플릿도 신청해 볼까요?": "Want to request a new template too?", + "신청하기": "Apply", + "홈 | 설정": "Home | Settings", + "토론 정보\n관리 및 기록": "Debate info\nManagement and records", + "토론 기본 정보 설정": "Set debate basics", + "시간표 이름부터 주제까지!": "From schedule name to Topic!", + "시간표 목록": "Schedule list", + "내가 만든 시간표를 저장하고 싶나요?": "Want to save the schedules you made?", + "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!": "To save your schedule,\nplease log in to Debate Timer!", + "3초 로그인 하기": "3-second login", + "아래로 스크롤": "Scroll down", + "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "Many people already use Debate Timer\nto create a better debate environment.", + "비회원으로 시작하기": "Start as guest", + "버그 및 불편사항 제보": "Report bugs or issues", + "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "Did you run into issues while using Debate Timer?", + "접수하기": "Submit", + "디베이트 타이머": "Debate Timer", + "개인정보처리방침": "Privacy Policy", + "서비스 이용약관": "Terms of Service", + "브라우저에서 비디오를 지원하지 않습니다.": "Your browser does not support video.", + "토론 진행을 더 쉽고 빠르게": "Run debates easier and faster", + "대시보드로 이동": "Go to Dashboard", + "3초 로그인": "3-second login", + "로그아웃": "Log out", + "왕관": "Crown", + "투표 세부 결과": "Detailed vote results", + "명_one": " person", + "명_other": " people", + "시간표로 돌아가기": "Back to schedule", + "비회원 모드": "Guest mode", + "홈으로 이동": "Go to Home", + "로그인": "Log in", + "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?": "You have schedules created in guest mode.\nDo you want to keep using them after you log in?", + "언어 선택": "Select language", + "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.": "For each team,\nchoose heads or tails\non the coin.", + "동전": "Coin", + "동전 던지는 중...": "Flipping coin...", + "앞": "Heads", + "뒤": "Tails", + "동전 던지기": "Flip coin", + "토론 정보 수정하기": "Edit debate info", + "토론 바로 시작하기": "Start debate now", + "입론": "Constructive", + "반론": "Rebuttal", + "최종발언": "Final Focus", + "작전시간": "Prep Time", + "교차조사": "Cross Fire", + "직접입력": "Custom", + "발언 시간은 1초 이상이어야 해요.": "Speaking Time must be at least 1 second.", + "종료 전 타종은 발언 시간보다 길 수 없어요.": "The bell before the end cannot be longer than the Speaking Time.", + "팀당 발언 시간은 1초 이상이어야 해요.": "Speaking Time per team must be at least 1 second.", + "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.": "Speaking Time per turn cannot exceed the team's total Speaking Time.", + "발언 유형은 최대 10자까지 입력할 수 있습니다.": "Speech Type can be up to 10 characters.", + "발언자는 최대 5자까지 입력할 수 있습니다.": "Speaker can be up to 5 characters.", + "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.": "Per-turn Speaking Time cannot exceed the total team Speaking Time.", + "발언 유형을 입력해주세요.": "Please enter a Speech Type.", + "자유토론": "Open Debate", + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "Neutral can only be selected when Speech Type is 'Custom'.", + "중립": "Neutral", + "일반 타이머": "Standard timer", + "자유토론 타이머": "Open Debate timer", + "한 팀의 발언 시간이 세팅된 일반적인 타이머": "A standard timer with a single team's Speaking Time set", + "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감": "A timer with team time and per-turn time set\nWhen a per-turn time ends, the turn passes to the other team", + "종류": "Type", + "발언자": "Speaker", + "N번 토론자": "Speaker N", + "발언 시간": "Speaking Time", + "1회당\n발언 시간": "Per-turn\nSpeaking Time", + "팀당\n발언 시간": "Per-team\nSpeaking Time", + "발언 유형": "Speech Type", + "주도권 토론 등": "e.g. Lead debate", + "입론, 반론, 작전 시간 등": "e.g. Constructive, Rebuttal, Prep Time", + "종소리 설정 접기": "Collapse bell settings", + "종소리 설정 펼치기": "Expand bell settings", + "종료 전": "Before end", + "종료 후": "After end", + "시작 후": "After start", + "분": "min", + "초": "sec", + "횟수": "Count", + "설정 완료": "Save settings", + "타이머 추가": "Add timer", + "수정 완료": "Save changes", + "추가하기": "Add", + "복사하기": "Copy", + "이 타이머를 삭제하시겠습니까?": "Delete this timer?", + "{{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec", + "팀당 {{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec per team", + "발언당 {{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec per speech", + "위/아래로 드래그": "Drag up/down", + " | {{speaker}} 토론자": " | {{speaker}} Debater", + "토론 정보를 {{val0}}해주세요": "Please {{val0}} the debate info", + "수정": "Edit", + "설정": "Settings", + "토론 시간표 이름": "Debate schedule name", + "토론 주제": "Debate Topic", + "토론 주제를 입력해주세요": "Please enter a debate Topic", + "팀명": "Team name", + "팀명은 최대 8자까지 입력할 수 있습니다.": "Team name can be up to 8 characters.", + "다음": "Next", + "볼륨 조절": "Volume control", + "투표 종료에 실패했습니다.": "Failed to end the vote.", + "마감하기": "Close voting", + "투표를 마감하시겠습니까?": "Do you want to close the vote?", + "투표를 마감하면 더이상 표를 받을 수 없습니다!": "Once you close the vote, you can no longer receive votes!", + "음소거": "Mute", + "음소거 해제": "Unmute", + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "Speaker can be up to {{MAX_SPEAKER_LEN}} characters.", + "400 잘못된 요청": "400 Bad Request", + "401 권한 없음": "401 Unauthorized", + "403 거부됨": "403 Forbidden", + "404 찾을 수 없음": "404 Not Found", + "500 내부 서버 오류": "500 Internal Server Error", + "502 게이트웨이 불량": "502 Bad Gateway", + "503 서비스가 일시적으로 중단됨": "503 Service Unavailable", + "504 게이트웨이 시간 초과": "504 Gateway Timeout" +} diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json new file mode 100644 index 00000000..940eaf84 --- /dev/null +++ b/public/locales/ko/translation.json @@ -0,0 +1,252 @@ +{ + "모달 닫기": "모달 닫기", + "유효하지 않은 투표 링크입니다.": "유효하지 않은 투표 링크입니다.", + "승패투표": "승패투표", + "참여자 :": "참여자 :", + "찬성팀": "찬성팀", + "반대팀": "반대팀", + "투표완료": "투표완료", + "다시 투표하기": "다시 투표하기", + "제출하기": "제출하기", + "투표를 제출하시겠습니까?": "투표를 제출하시겠습니까?", + "(제출 후에는 변경이 불가능 합니다.)": "(제출 후에는 변경이 불가능 합니다.)", + "투표가 완료되었습니다.": "투표가 완료되었습니다.", + "테이블 이름 없음": "테이블 이름 없음", + "주제 없음": "주제 없음", + "도움말": "도움말", + "전체 화면": "전체 화면", + "테이블 ID가 올바르지 않습니다.": "테이블 ID가 올바르지 않습니다.", + "공유받은 테이블을 저장하지 못했어요.": "공유받은 테이블을 저장하지 못했어요.", + "테이블 데이터를 확인할 수 없어요.": "테이블 데이터를 확인할 수 없어요.", + "공유된 데이터가 비어 있어요.": "공유된 데이터가 비어 있어요.", + "공유된 토론 테이블을 DB에 저장하지 못했어요.": "공유된 토론 테이블을 DB에 저장하지 못했어요.", + "데이터를 처리하고 있습니다...": "데이터를 처리하고 있습니다...", + "공유받은 데이터 처리에 실패했어요.": "공유받은 데이터 처리에 실패했어요.", + "팀 선정하기": "팀 선정하기", + "토론하기": "토론하기", + "토론 시간표를 선택해주세요": "토론 시간표를 선택해주세요", + "테이블 모드가 올바르지 않습니다.": "테이블 모드가 올바르지 않습니다.", + "시간표 1": "시간표 1", + "찬성": "찬성", + "반대": "반대", + "중립": "중립", + "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?", + "무승부": "무승부", + "유효하지 않은 투표 결과 링크입니다.": "유효하지 않은 투표 결과 링크입니다.", + "세부 결과 확인하기": "세부 결과 확인하기", + "아니오": "아니오", + "네": "네", + "정말로 세부 결과를 공개할까요?": "정말로 세부 결과를 공개할까요?", + "토론을 모두 마치셨습니다": "토론을 모두 마치셨습니다", + "박수": "박수", + "피드백 타이머": "피드백 타이머", + "심사평 및 Q&A용 타이머 →": "심사평 및 Q&A용 타이머 →", + "피드백 타이머로 이동": "피드백 타이머로 이동", + "승패투표 진행하기": "승패투표 진행하기", + "QR 코드를 통해 투표 페이지로 이동해요.": "QR 코드를 통해 투표 페이지로 이동해요.", + "승패투표 생성 및 진행": "승패투표 생성 및 진행", + "스캔해 주세요!": "스캔해 주세요!", + "참여자": "참여자", + "등록된 토론자가 없어요.": "등록된 토론자가 없어요.", + "투표 결과 보기": "투표 결과 보기", + "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?": "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?", + "링크가 클립보드에 복사됨": "링크가 클립보드에 복사됨", + "링크 준비 중": "링크 준비 중", + "공유 링크 복사": "공유 링크 복사", + "이전 차례": "이전 차례", + "다음 차례": "다음 차례", + "토론 종료": "토론 종료", + "알림 개수_one": "알림 {{displayCount}}개", + "알림 개수_other": "알림 {{displayCount}}개", + "{{team}} 팀": "{{team}} 팀", + "데이터를 불러오고 있습니다...": "데이터를 불러오고 있습니다...", + "토론 종료 화면으로 돌아가기": "토론 종료 화면으로 돌아가기", + "데이터를 불러오지 못했어요.\n다시 시도할까요?": "데이터를 불러오지 못했어요.\n다시 시도할까요?", + "다시 시도하기": "다시 시도하기", + "페이지를 찾을 수 없어요...": "페이지를 찾을 수 없어요...", + "요청 URL": "요청 URL", + "오류 내용": "오류 내용", + "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.": "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.", + "홈으로 돌아가기": "홈으로 돌아가기", + "{{status}} 오류": "{{status}} 오류", + "오류가 발생했어요...": "오류가 발생했어요...", + "스택": "스택", + "선택": "선택", + "타이머 초기화": "타이머 초기화", + "일시정지": "일시정지", + "재생": "재생", + "A키": "A키", + "L키": "L키", + "전체 시간": "전체 시간", + "현재 시간": "현재 시간", + "팀": "팀", + "토론자": "토론자", + "작전 시간 사용": "작전 시간 사용", + "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?": "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?", + "자유토론 타이머 조작": "자유토론 타이머 조작", + "재생 버튼을 눌러 타이머를 시작": "재생 버튼을 눌러 타이머를 시작", + "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지": "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지", + "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화": "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화", + "마우스를 사용하여 타이머를 클릭 시, 진영 변경": "마우스를 사용하여 타이머를 클릭 시, 진영 변경", + "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작": "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작", + "일반 토론 타이머 조작": "일반 토론 타이머 조작", + "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능": "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능", + "키보드 조작": "키보드 조작", + "스페이스 바로 타이머를 시작 및 일시정지": "스페이스 바로 타이머를 시작 및 일시정지", + "R 키로 타이머 초기화": "R 키로 타이머 초기화", + "좌우 방향키로 이전/다음 차례로 이동": "좌우 방향키로 이전/다음 차례로 이동", + "A/L 키로 토론 진영 변경": "A/L 키로 토론 진영 변경", + "Enter 키로 상대 진영으로 변경": "Enter 키로 상대 진영으로 변경", + "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화": "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화", + "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화": "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화", + "닫기": "닫기", + "작전 시간": "작전 시간", + "-1분": "-1분", + "-30초": "-30초", + "+30초": "+30초", + "+1분": "+1분", + "-5분": "-5분", + "+5분": "+5분", + "비회원 상태로 토론하기": "비회원 상태로 토론하기", + "저장하기": "저장하기", + "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?": "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?", + "수정하기": "수정하기", + "삭제하기": "삭제하기", + "공유하기": "공유하기", + "주제 | ": "주제 | ", + "취소": "취소", + "삭제": "삭제", + "테이블을 삭제하시겠습니까?": "테이블을 삭제하시겠습니까?", + "타이머 화면": "타이머 화면", + "원하는 때에\n작전 시간 사용하기": "원하는 때에\n작전 시간 사용하기", + "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요": "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요", + "작전 시간이 나타나면\n원하는 시간을 입력하세요!": "작전 시간이 나타나면\n원하는 시간을 입력하세요!", + "키보드 방향키로\n더 편리한 조작": "키보드 방향키로\n더 편리한 조작", + "시간표 설정화면": "시간표 설정화면", + "간편한 시간표 구성": "간편한 시간표 구성", + "시간표 추가": "시간표 추가", + "시간표 추가 버튼": "시간표 추가 버튼", + "두가지 타이머": "두가지 타이머", + "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.": "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.", + "종소리 설정": "종소리 설정", + "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.": "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.", + "다양한 토론 템플릿을 원클릭으로 만나보세요!": "다양한 토론 템플릿을 원클릭으로 만나보세요!", + "{{title}} 로고": "{{title}} 로고", + "{{label}} 토론하기": "{{label}} 토론하기", + "템플릿 신청하기": "템플릿 신청하기", + "새로운 템플릿도 신청해 볼까요?": "새로운 템플릿도 신청해 볼까요?", + "신청하기": "신청하기", + "홈 | 설정": "홈 | 설정", + "토론 정보\n관리 및 기록": "토론 정보\n관리 및 기록", + "토론 기본 정보 설정": "토론 기본 정보 설정", + "시간표 이름부터 주제까지!": "시간표 이름부터 주제까지!", + "시간표 목록": "시간표 목록", + "내가 만든 시간표를 저장하고 싶나요?": "내가 만든 시간표를 저장하고 싶나요?", + "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!": "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!", + "3초 로그인 하기": "3초 로그인 하기", + "아래로 스크롤": "아래로 스크롤", + "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.", + "비회원으로 시작하기": "비회원으로 시작하기", + "버그 및 불편사항 제보": "버그 및 불편사항 제보", + "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "디베이트 타이머 사용 중 불편함을 느끼셨나요?", + "접수하기": "접수하기", + "디베이트 타이머": "디베이트 타이머", + "개인정보처리방침": "개인정보처리방침", + "서비스 이용약관": "서비스 이용약관", + "브라우저에서 비디오를 지원하지 않습니다.": "브라우저에서 비디오를 지원하지 않습니다.", + "토론 진행을 더 쉽고 빠르게": "토론 진행을 더 쉽고 빠르게", + "대시보드로 이동": "대시보드로 이동", + "3초 로그인": "3초 로그인", + "로그아웃": "로그아웃", + "왕관": "왕관", + "투표 세부 결과": "투표 세부 결과", + "명_one": " 명", + "명_other": " 명", + "시간표로 돌아가기": "시간표로 돌아가기", + "비회원 모드": "비회원 모드", + "홈으로 이동": "홈으로 이동", + "로그인": "로그인", + "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?": "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?", + "언어 선택": "언어 선택", + "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.": "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.", + "동전": "동전", + "동전 던지는 중...": "동전 던지는 중...", + "앞": "앞", + "뒤": "뒤", + "동전 던지기": "동전 던지기", + "토론 정보 수정하기": "토론 정보 수정하기", + "토론 바로 시작하기": "토론 바로 시작하기", + "입론": "입론", + "반론": "반론", + "최종발언": "최종발언", + "작전시간": "작전시간", + "교차조사": "교차조사", + "직접입력": "직접입력", + "발언 시간은 1초 이상이어야 해요.": "발언 시간은 1초 이상이어야 해요.", + "종료 전 타종은 발언 시간보다 길 수 없어요.": "종료 전 타종은 발언 시간보다 길 수 없어요.", + "팀당 발언 시간은 1초 이상이어야 해요.": "팀당 발언 시간은 1초 이상이어야 해요.", + "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.": "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.", + "발언 유형은 최대 10자까지 입력할 수 있습니다.": "발언 유형은 최대 10자까지 입력할 수 있습니다.", + "발언자는 최대 5자까지 입력할 수 있습니다.": "발언자는 최대 5자까지 입력할 수 있습니다.", + "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.": "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.", + "발언 유형을 입력해주세요.": "발언 유형을 입력해주세요.", + "자유토론": "자유토론", + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.", + "일반 타이머": "일반 타이머", + "자유토론 타이머": "자유토론 타이머", + "한 팀의 발언 시간이 세팅된 일반적인 타이머": "한 팀의 발언 시간이 세팅된 일반적인 타이머", + "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감": "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감", + "종류": "종류", + "발언자": "발언자", + "N번 토론자": "N번 토론자", + "발언 시간": "발언 시간", + "1회당\n발언 시간": "1회당\n발언 시간", + "팀당\n발언 시간": "팀당\n발언 시간", + "발언 유형": "발언 유형", + "주도권 토론 등": "주도권 토론 등", + "입론, 반론, 작전 시간 등": "입론, 반론, 작전 시간 등", + "종소리 설정 접기": "종소리 설정 접기", + "종소리 설정 펼치기": "종소리 설정 펼치기", + "종료 전": "종료 전", + "종료 후": "종료 후", + "시작 후": "시작 후", + "분": "분", + "초": "초", + "횟수": "횟수", + "설정 완료": "설정 완료", + "타이머 추가": "타이머 추가", + "수정 완료": "수정 완료", + "추가하기": "추가하기", + "복사하기": "복사하기", + "이 타이머를 삭제하시겠습니까?": "이 타이머를 삭제하시겠습니까?", + "{{minutes}}분 {{seconds}}초": "{{minutes}}분 {{seconds}}초", + "팀당 {{minutes}}분 {{seconds}}초": "팀당 {{minutes}}분 {{seconds}}초", + "발언당 {{minutes}}분 {{seconds}}초": "발언당 {{minutes}}분 {{seconds}}초", + "위/아래로 드래그": "위/아래로 드래그", + " | {{speaker}} 토론자": " | {{speaker}} 토론자", + "토론 정보를 {{val0}}해주세요": "토론 정보를 {{val0}}해주세요", + "수정": "수정", + "설정": "설정", + "토론 시간표 이름": "토론 시간표 이름", + "토론 주제": "토론 주제", + "토론 주제를 입력해주세요": "토론 주제를 입력해주세요", + "팀명": "팀명", + "팀명은 최대 8자까지 입력할 수 있습니다.": "팀명은 최대 8자까지 입력할 수 있습니다.", + "다음": "다음", + "볼륨 조절": "볼륨 조절", + "투표 종료에 실패했습니다.": "투표 종료에 실패했습니다.", + "마감하기": "마감하기", + "투표를 마감하시겠습니까?": "투표를 마감하시겠습니까?", + "투표를 마감하면 더이상 표를 받을 수 없습니다!": "투표를 마감하면 더이상 표를 받을 수 없습니다!", + "음소거": "음소거", + "음소거 해제": "음소거 해제", + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.", + "400 잘못된 요청": "400 잘못된 요청", + "401 권한 없음": "401 권한 없음", + "403 거부됨": "403 거부됨", + "404 찾을 수 없음": "404 찾을 수 없음", + "500 내부 서버 오류": "500 내부 서버 오류", + "502 게이트웨이 불량": "502 게이트웨이 불량", + "503 서비스가 일시적으로 중단됨": "503 서비스가 일시적으로 중단됨", + "504 게이트웨이 시간 초과": "504 게이트웨이 시간 초과" +} diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts index 719ca2ac..57fcdfb1 100644 --- a/scripts/utils/astUtils.ts +++ b/scripts/utils/astUtils.ts @@ -116,7 +116,7 @@ export function transformAST(ast: t.File) { }, TemplateLiteral(path) { const { quasis, expressions } = path.node; - const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.raw)); + const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.cooked)); if (!hasKorean) return; if ( @@ -134,7 +134,7 @@ export function transformAST(ast: t.File) { const objectProperties: t.ObjectProperty[] = []; for (let i = 0; i < quasis.length; i++) { - i18nKey += quasis[i].value.raw; + i18nKey += quasis[i].value.cooked; if (i < expressions.length) { const expr = expressions[i]; let placeholderName: string; diff --git a/setup.ts b/setup.ts index ce75593c..48b1c9df 100644 --- a/setup.ts +++ b/setup.ts @@ -2,6 +2,7 @@ import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { server } from './src/mocks/server'; import { vi } from 'vitest'; +import i18n from './src/i18n'; // msw 서버 시작 beforeAll(() => { @@ -16,6 +17,17 @@ afterEach(() => server.resetHandlers()); // msw 서버 종료 afterAll(() => server.close()); +i18n.options.react = { + ...(i18n.options.react ?? {}), + useSuspense: false, +}; + +// 로컬스토리에 언어 설정 +if (typeof localStorage !== 'undefined') { + localStorage.setItem('i18nextLng', 'ko'); +} +i18n.changeLanguage('ko'); + // vitest.setup.ts 또는 setupTests.ts // ResizeObserver를 전역적으로 모킹합니다. global.ResizeObserver = class ResizeObserver { diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 89e9bede..d21d39ac 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -4,6 +4,12 @@ import { removeAccessToken, setAccessToken, } from '../util/accessToken'; +import i18n from '../i18n'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../util/languageRouting'; // Get current mode (DEV, PROD or TEST) const currentMode = import.meta.env.MODE; @@ -66,7 +72,9 @@ axiosInstance.interceptors.response.use( } catch (refreshError) { console.error('Refresh Token is invalid or expired', refreshError); // 재발급도 실패하면 -> 로그인 페이지 이동 - window.location.href = '/home'; + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + window.location.href = buildLangPath('/home', lang); removeAccessToken(); return Promise.reject(refreshError); } diff --git a/src/components/BackActionHandler.tsx b/src/components/BackActionHandler.tsx index e75bd923..0b3bbfe5 100644 --- a/src/components/BackActionHandler.tsx +++ b/src/components/BackActionHandler.tsx @@ -1,15 +1,30 @@ import { useCallback, useEffect } from 'react'; import { getAccessToken } from '../util/accessToken'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, + stripDefaultLangFromPath, +} from '../util/languageRouting'; export default function BackActionHandler() { + const { i18n } = useTranslation(); const navigate = useNavigate(); const handleBackAction = useCallback(() => { - if (getAccessToken() !== null && window.location.pathname === '/') { + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const rootPath = buildLangPath('/', lang); + const normalizedPathname = stripDefaultLangFromPath( + window.location.pathname, + ); + + if (getAccessToken() !== null && normalizedPathname === rootPath) { // Push the current state again to prevent going back - navigate('/'); + navigate(rootPath); } - }, [navigate]); + }, [i18n.language, i18n.resolvedLanguage, navigate]); useEffect(() => { const onPopState = () => { diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx index 827674e6..6b2d9e18 100644 --- a/src/components/DropdownMenu/DropdownMenu.tsx +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useRef, useEffect } from 'react'; import DTExpand from '../icons/Expand'; import clsx from 'clsx'; @@ -20,16 +21,17 @@ export default function DropdownMenu({ options, selectedValue, onSelect, - placeholder = '선택', + placeholder, disabled, className = '', }: DropdownMenuProps) { + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const selectedOptionLabel = options.find((option) => option.value === selectedValue)?.label || - placeholder; + (placeholder ?? t('선택')); // 드롭다운 외부 클릭 시 닫히도록 처리 useEffect(() => { diff --git a/src/components/ErrorBoundary/ErrorPage.tsx b/src/components/ErrorBoundary/ErrorPage.tsx index 51c69ccc..bc30de76 100644 --- a/src/components/ErrorBoundary/ErrorPage.tsx +++ b/src/components/ErrorBoundary/ErrorPage.tsx @@ -1,8 +1,14 @@ +import { useTranslation } from 'react-i18next'; import { IoHome } from 'react-icons/io5'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useNavigate } from 'react-router-dom'; import { APIError } from '../../apis/primitives'; import { ERROR_STATUS_TABLE } from '../../constants/errors'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; interface ErrorPageProps { error: Error; @@ -11,18 +17,28 @@ interface ErrorPageProps { } export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const homePath = buildLangPath('/home', lang); const goToHome = () => { onReset(); - navigate('/home', { replace: true }); + navigate(homePath, { replace: true }); }; // If error is from API request, print status code // to let user know exact reason of error. - const title = - error instanceof APIError - ? ERROR_STATUS_TABLE[error.status] || `${error.status} 오류` - : '오류가 발생했어요...'; + const title = (() => { + if (!(error instanceof APIError)) { + return t('오류가 발생했어요...'); + } + + const statusKey = ERROR_STATUS_TABLE[error.status]; + return statusKey + ? t(statusKey) + : t('{{status}} 오류', { status: error.status }); + })(); return ( @@ -40,12 +56,12 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) {
-

오류 내용

+

{t('오류 내용')}

{error.message}

-

스택

+

{t('스택')}

{stack}

@@ -56,7 +72,9 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { >
-

홈으로 돌아가기

+

+ {t('홈으로 돌아가기')} +

diff --git a/src/components/ErrorBoundary/NotFoundPage.tsx b/src/components/ErrorBoundary/NotFoundPage.tsx index 88dfd0dc..b72208ce 100644 --- a/src/components/ErrorBoundary/NotFoundPage.tsx +++ b/src/components/ErrorBoundary/NotFoundPage.tsx @@ -1,9 +1,19 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { IoHome } from 'react-icons/io5'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; export default function NotFoundPage() { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const rootPath = buildLangPath('/', lang); return ( @@ -17,32 +27,37 @@ export default function NotFoundPage() {

🤔

-

페이지를 찾을 수 없어요...

+

+ {t('페이지를 찾을 수 없어요...')} +

-

요청 URL

+

{t('요청 URL')}

{decodeURIComponent(window.location.href)}

-

오류 내용

-

- 요청하신 페이지를 찾을 수 없어요. 홈 화면으로 돌아가 처음부터 다시 - 시도해주세요. +

{t('오류 내용')}

+

+ {t( + '요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.', + )}

diff --git a/src/components/ErrorIndicator/ErrorIndicator.tsx b/src/components/ErrorIndicator/ErrorIndicator.tsx index 9f0b8b59..8e7312ff 100644 --- a/src/components/ErrorIndicator/ErrorIndicator.tsx +++ b/src/components/ErrorIndicator/ErrorIndicator.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { MdErrorOutline } from 'react-icons/md'; @@ -6,26 +7,23 @@ interface ErrorIndicatorProps extends PropsWithChildren { } export default function ErrorIndicator({ - children = ( - <> - 데이터를 불러오지 못했어요. -
- 다시 시도할까요? - - ), + children, onClickRetry, }: ErrorIndicatorProps) { + const { t } = useTranslation(); return (
-

{children}

+

+ {children ?? t('데이터를 불러오지 못했어요.\n다시 시도할까요?')} +

{onClickRetry && ( )}
diff --git a/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx index a94bbc4f..16b1a355 100644 --- a/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx +++ b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx @@ -1,5 +1,11 @@ +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import { useNavigate } from 'react-router-dom'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; interface GoToDebateEndButtonProps { tableId: number; @@ -10,22 +16,25 @@ export default function GoToDebateEndButton({ tableId, className = '', }: GoToDebateEndButtonProps) { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const handleClick = (tableId: number) => { - navigate(`/table/customize/${tableId}/end`); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + navigate(buildLangPath(`/table/customize/${tableId}/end`, lang)); }; return ( ); } diff --git a/src/components/GoogleButton.tsx b/src/components/GoogleButton.tsx deleted file mode 100644 index dec5eb9c..00000000 --- a/src/components/GoogleButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { ButtonHTMLAttributes } from 'react'; -import { FcGoogle } from 'react-icons/fc'; -import { isEmbeddedWebView } from '../util/validateUserAgent'; -import { MdOutlineErrorOutline } from 'react-icons/md'; -export default function GoogleButton( - props: ButtonHTMLAttributes, -) { - // Check whether user-agent is acceptable and set background color - const isDisabled = isEmbeddedWebView(); - const bgColor = isDisabled ? 'bg-gray-300' : 'bg-slate-100'; - const hoverBgColor = isDisabled ? '' : 'hover:bg-slate-200'; - console.log(isDisabled); - - return ( -
- {/* Google login button */} - - - {/* Error message */} - {isDisabled && ( -
- -

- 이 브라우저에서는 로그인이 불가능해요. 다른 웹 브라우저로 - 접속해주세요. -

-
- )} -
- ); -} diff --git a/src/components/HeaderTableInfo/HeaderTableInfo.tsx b/src/components/HeaderTableInfo/HeaderTableInfo.tsx index fcd70cae..2136e1be 100644 --- a/src/components/HeaderTableInfo/HeaderTableInfo.tsx +++ b/src/components/HeaderTableInfo/HeaderTableInfo.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import Skeleton from '../Skeleton/Skeleton'; interface HeaderTitleProps { @@ -6,8 +7,9 @@ interface HeaderTitleProps { } export default function HeaderTableInfo(props: HeaderTitleProps) { + const { t } = useTranslation(); const { name, skeletonEnabled: isLoading = false } = props; - const displayName = !name?.trim() ? '테이블 이름 없음' : name.trim(); + const displayName = !name?.trim() ? t('테이블 이름 없음') : name.trim(); return ( <> diff --git a/src/components/HeaderTitle/HeaderTitle.tsx b/src/components/HeaderTitle/HeaderTitle.tsx index d3bebe59..c871daf0 100644 --- a/src/components/HeaderTitle/HeaderTitle.tsx +++ b/src/components/HeaderTitle/HeaderTitle.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import Skeleton from '../Skeleton/Skeleton'; interface HeaderTitleProps { @@ -6,8 +7,9 @@ interface HeaderTitleProps { } export default function HeaderTitle(props: HeaderTitleProps) { + const { t } = useTranslation(); const { title, skeletonEnabled: isLoading = false } = props; - const displayTitle = !title?.trim() ? '주제 없음' : title.trim(); + const displayTitle = !title?.trim() ? t('주제 없음') : title.trim(); return ( <> diff --git a/src/components/LoadingIndicator/LoadingIndicator.tsx b/src/components/LoadingIndicator/LoadingIndicator.tsx index 83457713..e9635336 100644 --- a/src/components/LoadingIndicator/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator/LoadingIndicator.tsx @@ -1,13 +1,15 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import LoadingSpinner from '../LoadingSpinner'; -export default function LoadingIndicator({ - children = '데이터를 불러오고 있습니다...', -}: PropsWithChildren) { +export default function LoadingIndicator({ children }: PropsWithChildren) { + const { t } = useTranslation(); return (
-

{children}

+

+ {children ?? t('데이터를 불러오고 있습니다...')} +

); } diff --git a/src/components/NotificationBadge/NotificationBadge.tsx b/src/components/NotificationBadge/NotificationBadge.tsx index 3e2307fb..657b182b 100644 --- a/src/components/NotificationBadge/NotificationBadge.tsx +++ b/src/components/NotificationBadge/NotificationBadge.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; interface NotificationBadgeProps { @@ -9,6 +10,7 @@ export default function NotificationBadge({ count, className = '', }: NotificationBadgeProps) { + const { t } = useTranslation(); // 음수, NaN 등 의도하지 않은 값 확인 const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0; if (safeCount === 0) { @@ -17,10 +19,15 @@ export default function NotificationBadge({ const displayCount = safeCount > 99 ? '99+' : safeCount; + const ariaLabel = t('알림 개수', { + count: safeCount, + displayCount, + }); + return (
- {prosTeamName} + {prosTeamName ?? t('찬성')}
- {consTeamName} + + {consTeamName ?? t('반대')} +
diff --git a/src/components/RoundControlButton/RoundControlButton.tsx b/src/components/RoundControlButton/RoundControlButton.tsx index 69fd0052..8877f0ea 100644 --- a/src/components/RoundControlButton/RoundControlButton.tsx +++ b/src/components/RoundControlButton/RoundControlButton.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DTLeftArrow from '../icons/LeftArrow'; import DTRightArrow from '../icons/RightArrow'; @@ -12,6 +13,7 @@ export default function RoundControlButton({ type, onClick, }: RoundControlButtonProps) { + const { t } = useTranslation(); return ( diff --git a/src/components/ShareModal/ShareModal.tsx b/src/components/ShareModal/ShareModal.tsx index 62df8790..663155b7 100644 --- a/src/components/ShareModal/ShareModal.tsx +++ b/src/components/ShareModal/ShareModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { QRCodeSVG } from 'qrcode.react'; import { IoLinkOutline, IoShareOutline } from 'react-icons/io5'; import LoadingSpinner from '../LoadingSpinner'; @@ -21,12 +22,13 @@ export default function ShareModal({ onRefetch, onCopyClicked, }: ShareModalProps) { + const { t } = useTranslation(); // If error, print error message and let user be able to retry if (isError) { return (
onRefetch()}> - QR 코드를 불러오지 못했어요...

다시 시도하시겠어요? + {t('QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?')}
); @@ -44,7 +46,7 @@ export default function ShareModal({

- 링크가 클립보드에 복사됨 + {t('링크가 클립보드에 복사됨')}

@@ -89,7 +91,7 @@ export default function ShareModal({ }} > -

{isLoading ? '링크 준비 중' : '공유 링크 복사'}

+

{isLoading ? t('링크 준비 중') : t('공유 링크 복사')}

); diff --git a/src/components/VolumeBar/VolumeBar.tsx b/src/components/VolumeBar/VolumeBar.tsx index 6c9a076c..1b5f912a 100644 --- a/src/components/VolumeBar/VolumeBar.tsx +++ b/src/components/VolumeBar/VolumeBar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; // Integer 0-10, step = 1 // Mute button available import { useEffect, useState } from 'react'; @@ -20,6 +21,7 @@ export default function VolumeBar({ onVolumeChange, className = '', }: VolumeBarProps) { + const { t } = useTranslation(); // 음소거 해제 시 가장 마지막의 볼륨 값을 복원하기 위함 const [lastVolume, setLastVolume] = useState(volume > 0 ? volume : 5); @@ -61,6 +63,7 @@ export default function VolumeBar({ d="M164.025 18.1911C164.386 18.7925 165.037 19.1603 165.738 19.1603H227C228.105 19.1603 229 20.0558 229 21.1603V65.1662C229 66.2708 228.105 67.1662 227 67.1662H7.00001C5.89544 67.1662 5 66.2708 5 65.1662V21.1603C5 20.0558 5.89543 19.1603 7 19.1603H140.574C141.276 19.1603 141.926 18.7925 142.288 18.1911L151.442 2.96925C152.22 1.67692 154.093 1.67692 154.87 2.96925L164.025 18.1911Z" fill="white" /> + + @@ -91,11 +95,13 @@ export default function VolumeBar({ type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" /> + + { + const { t } = useTranslation(); if (!isOpen) return null; return ( @@ -72,7 +74,7 @@ export function useModal(options: UseModalOptions = {}) { type="button" onClick={closeModal} className={`absolute right-4 top-4 text-3xl ${closeButtonColor}`} - aria-label="모달 닫기" + aria-label={t('모달 닫기')} > diff --git a/src/hooks/useTableShare.tsx b/src/hooks/useTableShare.tsx index 8f8f9cd4..0fd2b07a 100644 --- a/src/hooks/useTableShare.tsx +++ b/src/hooks/useTableShare.tsx @@ -8,10 +8,7 @@ export function useTableShare(tableId: number) { const { isOpen, openModal, closeModal, ModalWrapper } = useModal(); const [copyState, setCopyState] = useState(false); const [shareUrl, setShareUrl] = useState(''); - const baseUrl = - import.meta.env.MODE !== 'production' - ? undefined - : import.meta.env.VITE_SHARE_BASE_URL; + const baseUrl = import.meta.env.VITE_SHARE_BASE_URL; const handleCopy = async () => { try { await navigator.clipboard.writeText(shareUrl); diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx new file mode 100644 index 00000000..ac370f13 --- /dev/null +++ b/src/layout/components/header/LanguageSelector.tsx @@ -0,0 +1,129 @@ +import { useTranslation } from 'react-i18next'; +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import clsx from 'clsx'; +import { DropdownMenuItem } from '../../../components/DropdownMenu/DropdownMenu'; // DropdownMenuItem 타입 재사용 +import DTExpand from '../../../components/icons/Expand'; +import { + buildLangPath, + getSelectedLangFromRoute, + isSupportedLang, +} from '../../../util/languageRouting'; +import i18n from '../../../i18n'; + +export default function LanguageSelector() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { lang: currentLangParam } = useParams(); + + const LANG_OPTIONS: DropdownMenuItem[] = [ + { value: 'ko', label: 'KR' }, + { value: 'en', label: 'EN' }, + ]; + + // URL 파라미터를 기반으로 현재 선택된 언어 결정 + // 유효한 언어 파라미터가 없으면 'ko'를 기본값으로 사용 + const selectedLangValue = getSelectedLangFromRoute( + currentLangParam, + location.pathname, + ); + const selectedLangLabel = + LANG_OPTIONS.find((option) => option.value === selectedLangValue)?.label || + 'KR'; + + const handleLanguageChange = (newLang: string) => { + if (!isSupportedLang(newLang)) { + return; + } + const newPathname = buildLangPath(location.pathname, newLang); + const nextUrl = `${newPathname}${location.search}${location.hash}`; + if (i18n.language !== newLang) { + i18n.changeLanguage(newLang); + } + navigate(nextUrl); + setIsMenuOpen(false); + }; + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [menuRef]); + + const triggerButtonClasses = clsx( + 'flex h-full cursor-pointer items-center justify-center gap-[8px] px-[4px] font-semibold leading-none text-default-black', + ); + + const menuPanelClasses = clsx( + 'absolute right-0 top-full z-10 mt-[16px] flex w-[68px] origin-top transform flex-col overflow-hidden border border-default-disabled/hover bg-default-white shadow-[0_3px_5px_rgba(0,0,0,0.2)] transition-opacity transition-transform duration-200 ease-out', + { + 'opacity-100 scale-y-100 pointer-events-auto': isMenuOpen, + 'opacity-0 scale-y-95 pointer-events-none': !isMenuOpen, + }, + ); + + const menuItemClasses = (value: string) => + clsx( + 'flex cursor-pointer items-center justify-center p-[10px] text-center text-body-raw font-semibold transition-colors duration-150 last:border-b-0 md:text-subtitle-raw', + { + 'text-default-black': value === selectedLangValue, + 'text-default-neutral': value !== selectedLangValue, + }, + ); + + return ( +
+ {/* 언어 선택 트리거 버튼 */} + + + {/* 언어 선택 메뉴 패널 */} +
+ {LANG_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index caf3ece9..5e7e115d 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { useNavigate } from 'react-router-dom'; import useLogout from '../../../hooks/mutations/useLogout'; @@ -12,8 +13,13 @@ import DialogModal from '../../../components/DialogModal/DialogModal'; import DTHome from '../../../components/icons/Home'; import DTLogin from '../../../components/icons/Login'; import useFullscreen from '../../../hooks/useFullscreen'; +import LanguageSelector from './LanguageSelector'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; -// The type of header icons will be declared here. type HeaderIcons = 'home' | 'auth'; function StickyTriSectionHeader(props: PropsWithChildren) { @@ -47,9 +53,13 @@ StickyTriSectionHeader.Center = function Center(props: PropsWithChildren) { }; StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { + const { t, i18n } = useTranslation(); const { children: buttons } = props; const navigate = useNavigate(); - const { mutate: logoutMutate } = useLogout(() => navigate('/home')); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const homePath = buildLangPath('/home', lang); + const { mutate: logoutMutate } = useLogout(() => navigate(homePath)); const { openModal, closeModal, ModalWrapper } = useModal({}); const { isFullscreen, setFullscreen } = useFullscreen(); const defaultIcons: HeaderIcons[] = ['home', 'auth']; @@ -64,29 +74,26 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { <>
{isGuestFlow() && ( - <> - {/* Guest mode indicator */} -
- 비회원 모드 -
- - {/* Vertical divider */} -
- +
+ {t('비회원 모드')} +
)} - {/* Buttons given as an argument */} + + +
+ + {/* props으로 들어오는 버튼들 */} {buttons} - {/* Normal buttons */} {defaultIcons.map((iconName, index) => { switch (iconName) { case 'home': return ( ); + case 'auth': return ( ); + default: return null; } @@ -135,18 +144,21 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { handleLoginStart(false), }} right={{ - text: '네', + text: t('네'), onClick: () => handleLoginStart(true), isBold: true, }} >
- 비회원으로 사용하던 시간표가 있습니다.
- 로그인 후에도 이 시간표를 계속 사용하시겠습니까? +

+ {t( + '비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?', + )} +

diff --git a/src/mocks/handlers/global.ts b/src/mocks/handlers/global.ts index f43ded4d..675b68a2 100644 --- a/src/mocks/handlers/global.ts +++ b/src/mocks/handlers/global.ts @@ -1,8 +1,47 @@ +import { http, HttpResponse } from 'msw'; import { customizeHandlers } from './customize'; import { memberHandlers } from './member'; import { pollHandlers } from './poll'; +const TRANSLATIONS: Record> = { + ko: { + '토론 시간표를 선택해주세요': '토론 시간표를 선택해주세요', + '토론 정보를 설정해주세요': '토론 정보를 설정해주세요', + '토론 정보를 수정해주세요': '토론 정보를 수정해주세요', + 다음: '다음', + 추가하기: '추가하기', + '타이머 추가': '타이머 추가', + '설정 완료': '설정 완료', + '주제 없음': '주제 없음', + '테이블을 삭제하시겠습니까?': '테이블을 삭제하시겠습니까?', + 취소: '취소', + 삭제하기: '삭제하기', + 수정하기: '수정하기', + }, + en: { + '토론 시간표를 선택해주세요': 'Please select a debate timetable', + '토론 정보를 설정해주세요': 'Please set the debate information', + '토론 정보를 수정해주세요': 'Please edit the debate information', + 다음: 'Next', + 추가하기: 'Add', + '타이머 추가': 'Add timer', + '설정 완료': 'Done', + '주제 없음': 'No topic', + '테이블을 삭제하시겠습니까?': 'Do you want to delete the table?', + 취소: 'Cancel', + 삭제하기: 'Delete', + 수정하기: 'Edit', + }, +}; + export const allHandlers = [ + http.get(/\/locales\/[^/]+\/translation\.json$/, ({ request }) => { + const pathname = new URL(request.url).pathname; + const match = pathname.match(/\/locales\/([^/]+)\/translation\.json$/); + const locale = match?.[1] ?? 'ko'; + const translations = TRANSLATIONS[locale] ?? TRANSLATIONS.ko; + return HttpResponse.json(translations); + }), ...memberHandlers, ...customizeHandlers, ...pollHandlers, diff --git a/src/page/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx index c695f400..27d7109b 100644 --- a/src/page/DebateEndPage/DebateEndPage.tsx +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import clapImage from '../../assets/debateEnd/clap.png'; @@ -6,17 +7,27 @@ import voteStampImage from '../../assets/debateEnd/vote_stamp.png'; import usePostPoll from '../../hooks/mutations/useCreatePoll'; import MenuCard from './components/MenuCard'; import GoToOverviewButton from './components/GoToOverviewButton'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; export default function DebateEndPage() { + const { t, i18n } = useTranslation(); const { id } = useParams(); const tableId = Number(id); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const handleFeedbackClick = () => { - navigate(`/table/customize/${tableId}/end/feedback`); + navigate(buildLangPath(`/table/customize/${tableId}/end/feedback`, lang)); }; const handleVoteClick = (pollId: number) => { - navigate(`/table/customize/${tableId}/end/vote/${pollId}`); + navigate( + buildLangPath(`/table/customize/${tableId}/end/vote/${pollId}`, lang), + ); }; const { mutate } = usePostPoll(handleVoteClick); @@ -27,7 +38,7 @@ export default function DebateEndPage() { // 테이블 ID 검증 if (!id || isNaN(tableId)) { - throw new Error('테이블 ID가 올바르지 않습니다.'); + throw new Error(t('테이블 ID가 올바르지 않습니다.')); } return ( @@ -37,32 +48,32 @@ export default function DebateEndPage() { >

- 토론을 모두 마치셨습니다 + {t('토론을 모두 마치셨습니다')}

박수
mutate(tableId)} - ariaLabel="승패투표 생성 및 진행" + ariaLabel={t('승패투표 생성 및 진행')} />
diff --git a/src/page/DebateEndPage/components/GoToOverviewButton.tsx b/src/page/DebateEndPage/components/GoToOverviewButton.tsx index 6300edf7..e667cdd0 100644 --- a/src/page/DebateEndPage/components/GoToOverviewButton.tsx +++ b/src/page/DebateEndPage/components/GoToOverviewButton.tsx @@ -1,6 +1,12 @@ +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import { RiCalendarScheduleLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; interface GoToOverviewButtonProps { tableId: number; @@ -11,15 +17,18 @@ export default function GoToOverviewButton({ tableId, className = '', }: GoToOverviewButtonProps) { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const handleClick = (tableId: number) => { - navigate(`/overview/customize/${tableId}`); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + navigate(buildLangPath(`/overview/customize/${tableId}`, lang)); }; return ( ); } diff --git a/src/page/DebateVotePage/DebateVotePage.tsx b/src/page/DebateVotePage/DebateVotePage.tsx index 90b27349..4f7924da 100644 --- a/src/page/DebateVotePage/DebateVotePage.tsx +++ b/src/page/DebateVotePage/DebateVotePage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { QRCodeSVG } from 'qrcode.react'; @@ -7,8 +8,17 @@ import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import useFetchEndPoll from '../../hooks/mutations/useFetchEndPoll'; import { useModal } from '../../hooks/useModal'; import DialogModal from '../../components/DialogModal/DialogModal'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; + export default function DebateVotePage() { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const baseUrl = import.meta.env.MODE !== 'production' ? undefined @@ -27,7 +37,12 @@ export default function DebateVotePage() { }, [baseUrl, pollId]); const handleGoToResult = () => { - navigate(`/table/customize/${tableId}/end/vote/${pollId}/result`); + navigate( + buildLangPath( + `/table/customize/${tableId}/end/vote/${pollId}/result`, + lang, + ), + ); }; const { @@ -48,7 +63,7 @@ export default function DebateVotePage() { }, onError: () => { closeModal(); - alert('투표 종료에 실패했습니다.'); + alert(t('투표 종료에 실패했습니다.')); }, }); }; @@ -71,8 +86,8 @@ export default function DebateVotePage() { return ( - navigate('/')}> - 유효하지 않은 투표 링크입니다. + navigate(buildLangPath('/', lang))}> + {t('유효하지 않은 투표 링크입니다.')} @@ -85,13 +100,13 @@ export default function DebateVotePage() {

- 승패투표 + {t('승패투표')}

-

스캔해 주세요!

+

{t('스캔해 주세요!')}

@@ -101,7 +116,8 @@ export default function DebateVotePage() {

- 참여자 + {t('참여자')} + ({participants?.length ?? 0}) @@ -109,7 +125,9 @@ export default function DebateVotePage() {

{!isLoading && participants && participants.length === 0 && ( -

등록된 토론자가 없어요.

+

+ {t('등록된 토론자가 없어요.')} +

)} {!isLoading && participants && participants.length > 0 && (
    @@ -134,7 +152,7 @@ export default function DebateVotePage() { onClick={openModal} className="button enabled brand flex flex-1 flex-row rounded-full p-[24px]" > - 투표 결과 보기 + {t('투표 결과 보기')}
@@ -143,15 +161,17 @@ export default function DebateVotePage() {
-

투표를 마감하시겠습니까?

+

+ {t('투표를 마감하시겠습니까?')} +

- 투표를 마감하면 더이상 표를 받을 수 없습니다! + {t('투표를 마감하면 더이상 표를 받을 수 없습니다!')}

diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index 3cf9110a..fc095ffc 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -9,7 +10,14 @@ import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import { TeamKey } from '../../type/type'; import { useCallback, useEffect, useState } from 'react'; import DialogModal from '../../components/DialogModal/DialogModal'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; + export default function DebateVoteResultPage() { + const { t, i18n } = useTranslation(); // 매개변수 검증 const { pollId: rawPollId, tableId: rawTableId } = useParams(); const pollId = rawPollId ? Number(rawPollId) : NaN; @@ -20,6 +28,9 @@ export default function DebateVoteResultPage() { const [isConfirmed, setIsConfirmed] = useState(false); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const rootPath = buildLangPath('/', lang); const { data, @@ -30,11 +41,13 @@ export default function DebateVoteResultPage() { isRefetchError, } = useGetPollInfo(pollId, { enabled: isArgsValid }); const handleGoHome = () => { - navigate('/'); + navigate(rootPath); }; const handleGoToEndPage = useCallback(() => { - navigate(`/table/customize/${tableId}/end`, { replace: true }); - }, [navigate, tableId]); + navigate(buildLangPath(`/table/customize/${tableId}/end`, lang), { + replace: true, + }); + }, [lang, navigate, tableId]); useEffect(() => { if (!isArgsValid) return; @@ -70,7 +83,7 @@ export default function DebateVoteResultPage() { } else { return { teamKey: null, - teamName: '무승부', + teamName: t('무승부'), }; } }; @@ -79,8 +92,8 @@ export default function DebateVoteResultPage() { return ( - navigate('/')}> - 유효하지 않은 투표 결과 링크입니다. + navigate(rootPath)}> + {t('유효하지 않은 투표 결과 링크입니다.')} @@ -96,8 +109,8 @@ export default function DebateVoteResultPage() { ); } const { teamKey, teamName } = getWinner({ - prosTeamName: data?.prosTeamName || '찬성팀', - consTeamName: data?.consTeamName || '반대팀', + prosTeamName: data?.prosTeamName || t('찬성팀'), + consTeamName: data?.consTeamName || t('반대팀'), prosCount: data?.prosCount || 0, consCount: data?.consCount || 0, }); @@ -107,7 +120,7 @@ export default function DebateVoteResultPage() {

- 승패투표 + {t('승패투표')}

@@ -123,7 +136,7 @@ export default function DebateVoteResultPage() { className="button enabled neutral flex w-full flex-1 rounded-full p-[24px]" disabled={isLoading} > - 토론 종료화면으로 + {t('토론 종료 화면으로 돌아가기')}
@@ -141,11 +154,11 @@ export default function DebateVoteResultPage() { {isConfirmed ? ( closeModal(), }} right={{ - text: '네', + text: t('네'), onClick: () => setIsConfirmed(true), isBold: true, }} >
- 정말로 세부 결과를 공개할까요? + {t('정말로 세부 결과를 공개할까요?')}
)} diff --git a/src/page/DebateVoteResultPage/components/VoteBar.tsx b/src/page/DebateVoteResultPage/components/VoteBar.tsx index 2a6c6439..d95ee397 100644 --- a/src/page/DebateVoteResultPage/components/VoteBar.tsx +++ b/src/page/DebateVoteResultPage/components/VoteBar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import AnimatedCounter from './AnimatedCounter'; import { TEAM_STYLE, TeamKey } from '../../../type/type'; @@ -17,9 +18,10 @@ export default function VoteBar({ total, heightClass = 'h-20', }: VoteBarProps) { + const { t } = useTranslation(); const style = TEAM_STYLE[teamKey]; const percentage = total > 0 ? (count / total) * 100 : 0; - const sideLabel = teamKey === 'PROS' ? '찬성팀' : '반대팀'; + const sideLabel = teamKey === 'PROS' ? t('찬성팀') : t('반대팀'); // 배경 바 색상은 좀 더 투명하게 const barTone = @@ -52,7 +54,8 @@ export default function VoteBar({
- 명 + + {t('명', { count })}
diff --git a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx index e22a2bcf..8198fba6 100644 --- a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx +++ b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx @@ -1,4 +1,4 @@ -// pages/VoteDetailResult.tsx +import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import VoteBar from './VoteBar'; @@ -13,6 +13,7 @@ export default function VoteDetailResult({ pros, cons, }: VoteDetailResultProps) { + const { t } = useTranslation(); return (

- 투표 세부 결과 + {t('투표 세부 결과')}

@@ -34,6 +35,7 @@ export default function VoteDetailResult({ count={pros.count} total={pros.count + cons.count} /> +
- 홈으로 돌아가기 + {t('홈으로 돌아가기')}
diff --git a/src/page/DebateVoteResultPage/components/WinnerCard.tsx b/src/page/DebateVoteResultPage/components/WinnerCard.tsx index 61c72528..eb013bcc 100644 --- a/src/page/DebateVoteResultPage/components/WinnerCard.tsx +++ b/src/page/DebateVoteResultPage/components/WinnerCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import crown from '../../../assets/debateEnd/crown.svg'; import { TEAM_STYLE, TeamKey } from '../../../type/type'; import clsx from 'clsx'; @@ -8,9 +9,14 @@ interface WinnerCardProps { } export default function WinnerCard({ teamkey, teamName }: WinnerCardProps) { + const { t } = useTranslation(); const style = teamkey ? TEAM_STYLE[teamkey] : null; const sideLabel = - teamkey === 'PROS' ? '찬성팀' : teamkey === 'CONS' ? '반대팀' : '무승부'; + teamkey === 'PROS' + ? t('찬성팀') + : teamkey === 'CONS' + ? t('반대팀') + : t('무승부'); return (
@@ -46,7 +52,7 @@ export default function WinnerCard({ teamkey, teamName }: WinnerCardProps) { {/* 왕관 — 무승부일 때는 표시 안 함 */} {teamkey && (
- 왕관 + {t('왕관')}
)}
diff --git a/src/page/LandingPage/components/Header.tsx b/src/page/LandingPage/components/Header.tsx index df4de7d9..0d8df66f 100644 --- a/src/page/LandingPage/components/Header.tsx +++ b/src/page/LandingPage/components/Header.tsx @@ -1,11 +1,14 @@ +import { useTranslation } from 'react-i18next'; import { useState, useEffect } from 'react'; import { isLoggedIn } from '../../../util/accessToken'; +import LanguageSelector from '../../../layout/components/header/LanguageSelector'; interface HeaderProps { onLoginButtonClicked: () => void; } export default function Header({ onLoginButtonClicked }: HeaderProps) { + const { t } = useTranslation(); const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { @@ -24,15 +27,18 @@ export default function Header({ onLoginButtonClicked }: HeaderProps) { }`} >
-
+

Debate Timer +

+
+ +
-
); diff --git a/src/page/LandingPage/components/MainSection.tsx b/src/page/LandingPage/components/MainSection.tsx index bd86ad77..ddd70fa5 100644 --- a/src/page/LandingPage/components/MainSection.tsx +++ b/src/page/LandingPage/components/MainSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import preview from '../../../assets/landing/preview.webm'; import { isLoggedIn } from '../../../util/accessToken'; @@ -10,22 +11,23 @@ export default function MainSection({ onStartWithoutLogin, onDashboardButtonClicked, }: MainSectionProps) { + const { t } = useTranslation(); return (

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

); diff --git a/src/page/LandingPage/components/ReportSection.tsx b/src/page/LandingPage/components/ReportSection.tsx index 57a20ea6..86365e78 100644 --- a/src/page/LandingPage/components/ReportSection.tsx +++ b/src/page/LandingPage/components/ReportSection.tsx @@ -1,16 +1,18 @@ +import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; export default function ReportSection() { + const { t } = useTranslation(); return (

- 버그 및 불편사항 제보 + {t('버그 및 불편사항 제보')}

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

section501

- 디베이트 타이머 + {t('디베이트 타이머')}

| @@ -57,7 +59,7 @@ export default function ReportSection() { } className="text-[min(max(0.75rem,1vw),1rem)] text-neutral-500 transition-colors hover:text-neutral-700" > - 서비스 이용약관 + {t('서비스 이용약관')}
diff --git a/src/page/LandingPage/components/ReviewSection.tsx b/src/page/LandingPage/components/ReviewSection.tsx index 81a0b754..d63a245d 100644 --- a/src/page/LandingPage/components/ReviewSection.tsx +++ b/src/page/LandingPage/components/ReviewSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import ReviewCard from './ReviewCard'; import { REVIEWS } from '../../../constants/reviews'; @@ -8,14 +9,18 @@ interface ReviewSectionProps { export default function ReviewSection({ onStartWithoutLogin, }: ReviewSectionProps) { + const { t } = useTranslation(); return (
-

이미 많은 사람들이 디베이트 타이머로

-

더 나은 토론환경을 만들고 있어요.

+

+ {t( + '이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.', + )} +

{REVIEWS.map((review) => ( @@ -27,7 +32,7 @@ export default function ReviewSection({ className="rounded-full border border-neutral-300 bg-brand px-20 py-2 text-[min(max(0.875rem,1.25vw),1.2rem)] font-medium text-default-black transition-all duration-100 hover:bg-semantic-table hover:text-default-white" onClick={onStartWithoutLogin} > - 비회원으로 시작하기 + {t('비회원으로 시작하기')}
diff --git a/src/page/LandingPage/components/ScrollHint.tsx b/src/page/LandingPage/components/ScrollHint.tsx index 648ce25a..0bb216b1 100644 --- a/src/page/LandingPage/components/ScrollHint.tsx +++ b/src/page/LandingPage/components/ScrollHint.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useEffect, useState } from 'react'; import arrowDown from '../../../assets/landing/bottom_arrow.png'; type ScrollHintProps = { @@ -5,6 +6,7 @@ type ScrollHintProps = { }; export default function ScrollHint({ topThreshold = 10 }: ScrollHintProps) { + const { t } = useTranslation(); const [visible, setVisible] = useState(true); useEffect(() => { @@ -39,7 +41,7 @@ export default function ScrollHint({ topThreshold = 10 }: ScrollHintProps) { > 아래로 스크롤
diff --git a/src/page/LandingPage/components/TableSection.tsx b/src/page/LandingPage/components/TableSection.tsx index 3cab8a18..aa5aef38 100644 --- a/src/page/LandingPage/components/TableSection.tsx +++ b/src/page/LandingPage/components/TableSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import section301 from '../../../assets/landing/debate_info_setting.png'; import section302 from '../../../assets/landing/table_list.png'; @@ -6,25 +7,25 @@ interface TableSectionProps { } export default function TableSection({ onLogin }: TableSectionProps) { + const { t } = useTranslation(); return (
- 홈 | 설정 + {t('홈 | 설정')}
-

- 토론 정보
- 관리 및 기록 +

+ {t('토론 정보\n관리 및 기록')}

- 토론 기본 정보 설정 + {t('토론 기본 정보 설정')}

- 시간표 이름부터 주제까지! + {t('시간표 이름부터 주제까지!')}

section301 @@ -33,22 +34,23 @@ export default function TableSection({ onLogin }: TableSectionProps) { section302

- 시간표 목록 + {t('시간표 목록')}

- 내가 만든 시간표를 저장하고 싶나요? + {t('내가 만든 시간표를 저장하고 싶나요?')}

-

시간표를 저장하려면,

-

디베이트 타이머에 로그인해 보세요!

+

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

diff --git a/src/page/LandingPage/components/TemplateApplicationSection.tsx b/src/page/LandingPage/components/TemplateApplicationSection.tsx index e9fb0747..43578023 100644 --- a/src/page/LandingPage/components/TemplateApplicationSection.tsx +++ b/src/page/LandingPage/components/TemplateApplicationSection.tsx @@ -1,15 +1,17 @@ +import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; export default function TemplateApplicationSection() { + const { t } = useTranslation(); return (

- 템플릿 신청하기 + {t('템플릿 신청하기')}

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

section501 diff --git a/src/page/LandingPage/components/TemplateCard.tsx b/src/page/LandingPage/components/TemplateCard.tsx index 0bc90367..f0ee93dd 100644 --- a/src/page/LandingPage/components/TemplateCard.tsx +++ b/src/page/LandingPage/components/TemplateCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { DebateTemplate } from '../../../type/type'; import clsx from 'clsx'; @@ -8,6 +9,7 @@ export default function TemplateCard({ actions, className, }: DebateTemplate) { + const { t } = useTranslation(); return (
diff --git a/src/page/LandingPage/components/TemplateSelection.tsx b/src/page/LandingPage/components/TemplateSelection.tsx index d7afa6ee..a238875f 100644 --- a/src/page/LandingPage/components/TemplateSelection.tsx +++ b/src/page/LandingPage/components/TemplateSelection.tsx @@ -1,13 +1,15 @@ +import { useTranslation } from 'react-i18next'; import { DEBATE_TEMPLATE } from '../../../constants/debate_template'; import TemplateApplicationSection from './TemplateApplicationSection'; import TemplateList from './TemplateList'; export default function TemplateSelection() { + const { t } = useTranslation(); return (

- 다양한 토론 템플릿을 원클릭으로 만나보세요! + {t('다양한 토론 템플릿을 원클릭으로 만나보세요!')}

diff --git a/src/page/LandingPage/components/TimeTableSection.tsx b/src/page/LandingPage/components/TimeTableSection.tsx index a9eb24ed..6ed9f91a 100644 --- a/src/page/LandingPage/components/TimeTableSection.tsx +++ b/src/page/LandingPage/components/TimeTableSection.tsx @@ -1,18 +1,20 @@ +import { useTranslation } from 'react-i18next'; import timeboxStep from '../../../assets/landing/timebox_step.png'; import timeboxButtons from '../../../assets/landing/timebox_step_button.png'; import bellSetting from '../../../assets/landing/bell_setting.png'; import twoTimer from '../../../assets/landing/two_timer.png'; import timeboxAddButton from '../../../assets/landing/timebox_add_button.png'; export default function TimeTableSection() { + const { t } = useTranslation(); return (
- 시간표 설정화면 + {t('시간표 설정화면')}

- 간편한 시간표 구성 + {t('간편한 시간표 구성')}

@@ -21,11 +23,11 @@ export default function TimeTableSection() {

- 시간표 추가 + {t('시간표 추가')}

시간표 추가 버튼
@@ -33,24 +35,20 @@ export default function TimeTableSection() { section302

- 두가지 타이머 + {t('두가지 타이머')}

-

- 일반형과 자유토론형 타이머로, -
- 다양한 토론 방식을 지원해요. +

+ {t('일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.')}

- 종소리 설정 + {t('종소리 설정')}

-

- 시간에 따른 종소리를 내마음대로 -
- 커스터마이징 할 수 있어요. +

+ {t('시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.')}

section302 diff --git a/src/page/LandingPage/components/TimerSection.tsx b/src/page/LandingPage/components/TimerSection.tsx index bbcc1d01..ff8bb07b 100644 --- a/src/page/LandingPage/components/TimerSection.tsx +++ b/src/page/LandingPage/components/TimerSection.tsx @@ -1,9 +1,12 @@ +import { Trans, useTranslation } from 'react-i18next'; import timer from '../../../assets/landing/timer.png'; import timerOperationTime from '../../../assets/landing/timer_operation_time.png'; import timerTimeBased from '../../../assets/landing/timer_timebased.png'; import keyInfo from '../../../assets/landing/key_info.png'; import timeoutButton from '../../../assets/landing/timeout_button.png'; + export default function TimerSection() { + const { t } = useTranslation(); return (
- 타이머 화면 + {t('타이머 화면')}
-

- 원하는 때에
- 작전 시간 사용하기 +

+ {t('원하는 때에\n작전 시간 사용하기')}

section301
-

- 토론자가 작전 시간을 -
- 요청하면{' '} - 작전 시간 사용{' '} -
- 버튼을 눌러 시간을 사용해요 +

+ \n버튼을 눌러 시간을 사용해요' + } + components={[ + {t('작전, + ]} + />

-

- 작전 시간이 나타나면 -
원하는 시간을 입력하세요! +

+ {t('작전 시간이 나타나면\n원하는 시간을 입력하세요!')}

section302
-

- 키보드 방향키로
더 편리한 조작 +

+ {t('키보드 방향키로\n더 편리한 조작')}

diff --git a/src/page/LandingPage/hooks/useLandingPageHandlers.ts b/src/page/LandingPage/hooks/useLandingPageHandlers.ts index 80b94b0a..7f3508a4 100644 --- a/src/page/LandingPage/hooks/useLandingPageHandlers.ts +++ b/src/page/LandingPage/hooks/useLandingPageHandlers.ts @@ -5,11 +5,22 @@ import useLogout from '../../../hooks/mutations/useLogout'; import { createTableShareUrl } from '../../../util/arrayEncoding'; import { SAMPLE_TABLE_DATA } from '../../../constants/sample_table'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; const useLandingPageHandlers = () => { // Prepare dependencies const navigate = useNavigate(); - const { mutate: logoutMutate } = useLogout(() => navigate('/home')); + const { i18n } = useTranslation(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const homePath = buildLangPath('/home', lang); + const rootPath = buildLangPath('/', lang); + const { mutate: logoutMutate } = useLogout(() => navigate(homePath)); // Declare functions that represent business logics const handleStartWithoutLogin = useCallback(() => { @@ -23,12 +34,12 @@ const useLandingPageHandlers = () => { if (!isLoggedIn()) { oAuthLogin(); } else { - navigate('/'); + navigate(rootPath); } - }, [navigate]); + }, [navigate, rootPath]); const handleDashboardButtonClick = useCallback(() => { - navigate('/'); - }, [navigate]); + navigate(rootPath); + }, [navigate, rootPath]); const handleHeaderLoginButtonClick = useCallback(() => { if (!isLoggedIn()) { oAuthLogin(); diff --git a/src/page/OAuthPage/OAuth.tsx b/src/page/OAuthPage/OAuth.tsx index 0262753a..52f73a88 100644 --- a/src/page/OAuthPage/OAuth.tsx +++ b/src/page/OAuthPage/OAuth.tsx @@ -5,11 +5,20 @@ import { deleteSessionCustomizeTableData, isGuestFlow, } from '../../util/sessionStorage'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; export default function OAuth() { + const { i18n } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const hasProcessedLogin = useRef(false); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const { mutate } = usePostUser(() => { const keepGuestTable = sessionStorage.getItem('keepGuestTable'); @@ -21,9 +30,9 @@ export default function OAuth() { sessionStorage.removeItem('keepGuestTable'); if (isGuestFlow()) { - navigate('/share'); + navigate(buildLangPath('/share', lang)); } else { - navigate('/'); + navigate(buildLangPath('/', lang)); } }); diff --git a/src/page/TableComposition/TableCompositionPage.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx index 07954359..e4f076f3 100644 --- a/src/page/TableComposition/TableCompositionPage.test.tsx +++ b/src/page/TableComposition/TableCompositionPage.test.tsx @@ -26,6 +26,10 @@ function TestWrapper({ {/* 실제로 이동하고 싶은 /overview 경로 - 테스트용 컴포넌트 */} + Overview Page} + /> Overview Page} diff --git a/src/page/TableComposition/TableCompositionPage.tsx b/src/page/TableComposition/TableCompositionPage.tsx index f528cced..858c07f4 100644 --- a/src/page/TableComposition/TableCompositionPage.tsx +++ b/src/page/TableComposition/TableCompositionPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import TableNameAndType from './components/TableNameAndType/TableNameAndType'; import useFunnel from '../../hooks/useFunnel'; @@ -14,13 +15,14 @@ export type TableCompositionStep = 'NameAndType' | 'TimeBox'; type Mode = 'edit' | 'add'; export default function TableCompositionPage() { + const { t } = useTranslation(); // URL 등으로부터 "editMode"와 "tableId"를 추출 const [searchParams] = useSearchParams(); const rawMode = searchParams.get('mode'); const rawTableId = searchParams.get('tableId'); if (rawMode !== 'edit' && rawMode !== 'add') { - throw new Error('테이블 모드가 올바르지 않습니다.'); + throw new Error(t('테이블 모드가 올바르지 않습니다.')); } const mode = rawMode as Mode; @@ -29,7 +31,7 @@ export default function TableCompositionPage() { mode === 'edit' && (rawTableId === null || isNaN(Number(rawTableId))) ) { - throw new Error('테이블 ID가 올바르지 않습니다.'); + throw new Error(t('테이블 ID가 올바르지 않습니다.')); } const tableId = rawTableId ? Number(rawTableId) : 0; @@ -78,9 +80,9 @@ export default function TableCompositionPage() { const handleButtonClick = () => { const patchedInfo = { ...formData.info, - name: formData.info.name ?? '시간표 1', - prosTeamName: formData.info.prosTeamName ?? '찬성', - consTeamName: formData.info.consTeamName ?? '반대', + name: formData.info.name ?? t('시간표 1'), + prosTeamName: formData.info.prosTeamName ?? t('찬성'), + consTeamName: formData.info.consTeamName ?? t('반대'), }; updateInfo(patchedInfo); @@ -97,7 +99,7 @@ export default function TableCompositionPage() { refetch()}> - 시간표 정보를 불러오지 못했어요...

다시 시도할까요? + {t('시간표 정보를 불러오지 못했어요.\n다시 시도할까요?')}
@@ -119,6 +121,7 @@ export default function TableCompositionPage() { onButtonClick={() => goToStep('TimeBox')} /> ), + TimeBox: ( @@ -59,29 +63,30 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
handleFieldChange('name', e.target.value)} onClear={() => clearField('name')} - placeholder="시간표 1" + placeholder={t('시간표 1')} disabled={isLoading} /> handleFieldChange('agenda', e.target.value)} onClear={() => clearField('agenda')} - placeholder="토론 주제를 입력해주세요" + placeholder={t('토론 주제를 입력해주세요')} disabled={isLoading} /> + <>
+ vs. 8 || cons.length > 8; if (isTooLong) { - alert('팀명은 최대 8자까지 입력할 수 있습니다.'); + alert(t('팀명은 최대 8자까지 입력할 수 있습니다.')); return; } const updatedInfo = { ...info, - name: info.name || '시간표 1', - prosTeamName: info.prosTeamName || '찬성', - consTeamName: info.consTeamName || '반대', + name: info.name || t('시간표 1'), + prosTeamName: info.prosTeamName || t('찬성'), + consTeamName: info.consTeamName || t('반대'), }; onInfoChange(updatedInfo); @@ -141,7 +147,7 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { }} className="button enabled brand w-full rounded-full" > - 다음 + {t('다음')}
diff --git a/src/page/TableComposition/components/TimeBox/TimeBox.tsx b/src/page/TableComposition/components/TimeBox/TimeBox.tsx index d6a2074b..1dda9a3b 100644 --- a/src/page/TableComposition/components/TimeBox/TimeBox.tsx +++ b/src/page/TableComposition/components/TimeBox/TimeBox.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { HTMLAttributes } from 'react'; import TimeBoxManageButtons from '../TimeBoxManageButtons/TimeBoxManageButtons'; import { TimeBoxInfo } from '../../../../type/type'; @@ -5,6 +6,7 @@ import { Formatting } from '../../../../util/formatting'; import DTDrag from '../../../../components/icons/Drag'; import SmallIconButtonContainer from '../../../../components/SmallIconContainer/SmallIconContainer'; import clsx from 'clsx'; +import { normalizeSpeechTypeKey } from '../../../../util/speechType'; interface TimeBoxEventHandlers { onSubmitEdit?: (updatedInfo: TimeBoxInfo) => void; @@ -20,6 +22,7 @@ interface TimeBoxProps extends HTMLAttributes { } export default function TimeBox(props: TimeBoxProps) { + const { t } = useTranslation(); const { stance, speechType, @@ -35,23 +38,31 @@ export default function TimeBox(props: TimeBoxProps) { const onSubmitCopy = eventHandlers?.onSubmitCopy; const onMouseDown = eventHandlers?.onMouseDown; const isModifiable = !!eventHandlers; + + const getSpeechTypeLabel = (value: string) => { + const normalized = normalizeSpeechTypeKey(value); + return normalized ? t(normalized) : value; + }; let timeStr = ''; let timePerSpeakingStr = ''; if (boxType === 'NORMAL') { const { minutes, seconds } = Formatting.formatSecondsToMinutes(time!); - timeStr = `${minutes}분 ${seconds}초`; + timeStr = t('{{minutes}}분 {{seconds}}초', { minutes, seconds }); } else { const { minutes, seconds } = Formatting.formatSecondsToMinutes( timePerTeam!, ); - timeStr = `팀당 ${minutes}분 ${seconds}초`; + timeStr = t('팀당 {{minutes}}분 {{seconds}}초', { minutes, seconds }); } if (timePerSpeaking !== null) { const { minutes, seconds } = Formatting.formatSecondsToMinutes(timePerSpeaking); - timePerSpeakingStr = `발언당 ${minutes}분 ${seconds}초`; + timePerSpeakingStr = t('발언당 {{minutes}}분 {{seconds}}초', { + minutes, + seconds, + }); } const fullTimeStr = timePerSpeakingStr ? `${timeStr} | ${timePerSpeakingStr}` @@ -75,7 +86,7 @@ export default function TimeBox(props: TimeBoxProps) { ${isPros ? 'right-[10px]' : 'left-[10px]'} `} onMouseDown={onMouseDown} - title="위/아래로 드래그" + title={t('위/아래로 드래그')} > @@ -126,9 +137,11 @@ export default function TimeBox(props: TimeBoxProps) { })} >

- {speechType} + {getSpeechTypeLabel(speechType)} {speaker && ( - {` | ${speaker} 토론자`} + + {t(' | {{speaker}} 토론자', { speaker })} + )}

@@ -152,7 +165,7 @@ export default function TimeBox(props: TimeBoxProps) { />

- {speechType} + {getSpeechTypeLabel(speechType)}

{timeStr}

@@ -178,7 +191,7 @@ export default function TimeBox(props: TimeBoxProps) { )} - {speechType} + {getSpeechTypeLabel(speechType)} {fullTimeStr} diff --git a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx index eccbfcb3..09070d90 100644 --- a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx +++ b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { TimeBoxInfo } from '../../../../type/type'; import { useModal } from '../../../../hooks/useModal'; import TimerCreationContent from '../TimerCreationContent/TimerCreationContent'; @@ -19,6 +20,7 @@ interface TimeBoxManageButtonsProps { } export default function TimeBoxManageButtons(props: TimeBoxManageButtonsProps) { + const { t } = useTranslation(); const { openModal: openEditModal, closeModal: closeEditModal, @@ -38,21 +40,21 @@ export default function TimeBoxManageButtons(props: TimeBoxManageButtonsProps) { <>
{onSubmitEdit && ( - )} {onSubmitDelete && ( - )} {onSubmitCopy && ( -
@@ -287,7 +291,7 @@ export default function TimeBoxStep(props: TimeBoxStepProps) { disabled={isLoading} > - 토론 정보 수정하기 + {t('토론 정보 수정하기')}
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx index 160362b0..e962fbb2 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; import TimerCreationContentItem from './TimerCreationContentMenuItem'; @@ -16,6 +17,7 @@ export default function TimeInputGroup({ onMinutesChange, onSecondsChange, }: TimeInputGroupProps) { + const { t } = useTranslation(); const validateTime = (value: string) => value === '' ? 0 : Math.max(0, Math.min(59, Number(value))); @@ -29,7 +31,8 @@ export default function TimeInputGroup({ onChange={(e) => onMinutesChange(validateTime(e.target.value))} onClear={() => onMinutesChange(0)} /> -

+ +

{t('분')}

@@ -39,7 +42,8 @@ export default function TimeInputGroup({ onChange={(e) => onSecondsChange(validateTime(e.target.value))} onClear={() => onSecondsChange(0)} /> -

+ +

{t('초')}

diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 1557f2b1..98974a0d 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -1,13 +1,17 @@ +import { useTranslation } from 'react-i18next'; import { useCallback, useMemo, useState } from 'react'; import { TimeBoxInfo, Stance, TimeBoxType, BellType, - BellTypeToString, BellConfig, } from '../../../../type/type'; import { Formatting } from '../../../../util/formatting'; +import { + SPEECH_TYPE_RECORD, + type SpeechTypeKey, +} from '../../../../util/speechType'; import normalTimerProsImage from '../../../../assets/timer/normal_timer_pros.jpg'; import normalTimerConsImage from '../../../../assets/timer/normal_timer_cons.jpg'; import normalTimerNeutralImage from '../../../../assets/timer/normal_timer_neutral.jpg'; @@ -38,22 +42,7 @@ type TimerCreationOption = | 'TIME_NORMAL' | 'BELL'; -type SpeechType = - | 'OPENING' - | 'REBUTTAL' - | 'TIMEOUT' - | 'CLOSING' - | 'CROSS_EXAM' - | 'CUSTOM'; - -const SPEECH_TYPE_RECORD: Record = { - OPENING: '입론', - CLOSING: '최종 발언', - CUSTOM: '직접 입력', - REBUTTAL: '반론', - CROSS_EXAM: '교차 조사', - TIMEOUT: '작전 시간', -} as const; +type SpeechType = SpeechTypeKey; const STANCE_RECORD: Record = { PROS: '찬성', @@ -61,6 +50,12 @@ const STANCE_RECORD: Record = { NEUTRAL: '중립', } as const; +const BELL_TYPE_LABEL_KEYS: Record = { + BEFORE_END: '종료 전', + AFTER_END: '종료 후', + AFTER_START: '시작 후', +} as const; + const NORMAL_OPTIONS: TimerCreationOption[] = [ 'TIMER_TYPE', 'SPEECH_TYPE_NORMAL', @@ -104,6 +99,7 @@ export default function TimerCreationContent({ onSubmit, onClose, }: TimerCreationContentProps) { + const { t } = useTranslation(); const [stance, setStance] = useState( beforeData?.stance ? beforeData?.stance === 'NEUTRAL' @@ -119,20 +115,21 @@ export default function TimerCreationContent({ // 발언 유형 초기화 const getSpeechTypeFromString = (value: string): SpeechType => { - switch (value.trim()) { - case '입론': + const normalize = (val: string) => val.replace(/\s+/g, '').trim(); + const normalized = normalize(value); + switch (normalized) { + case normalize(SPEECH_TYPE_RECORD.OPENING): return 'OPENING'; - case '반론': + case normalize(SPEECH_TYPE_RECORD.REBUTTAL): return 'REBUTTAL'; - case '최종발언': - case '최종 발언': + case normalize(SPEECH_TYPE_RECORD.CLOSING): return 'CLOSING'; - case '작전시간': - case '작전 시간': + case normalize(SPEECH_TYPE_RECORD.TIMEOUT): return 'TIMEOUT'; - case '교차조사': - case '교차 조사': + case normalize(SPEECH_TYPE_RECORD.CROSS_EXAM): return 'CROSS_EXAM'; + case normalize(SPEECH_TYPE_RECORD.OPEN_DEBATE): + return 'OPEN_DEBATE'; default: return 'CUSTOM'; } @@ -148,14 +145,20 @@ export default function TimerCreationContent({ }, []); const initSpeechType = - beforeData?.speechType ?? initData?.speechType ?? '입론'; + beforeData?.speechType ?? + initData?.speechType ?? + SPEECH_TYPE_RECORD.OPENING; + const isTimeBasedInit = + (beforeData?.boxType ?? initData?.boxType ?? 'NORMAL') === 'TIME_BASED'; const [currentSpeechType, setCurrentSpeechType] = useState( - getSpeechTypeFromString(initSpeechType), + isTimeBasedInit ? 'CUSTOM' : getSpeechTypeFromString(initSpeechType), ); const [speechTypeTextValue, setSpeechTypeTextValue] = useState( - currentSpeechType === 'CUSTOM' - ? (initData?.speechType ?? '') - : SPEECH_TYPE_RECORD[currentSpeechType], + isTimeBasedInit + ? initSpeechType + : currentSpeechType === 'CUSTOM' + ? (initData?.speechType ?? '') + : t(SPEECH_TYPE_RECORD[currentSpeechType]), ); // 종소리 영역 확장 여부 @@ -204,6 +207,7 @@ export default function TimerCreationContent({ { type: 'BEFORE_END', min: 0, sec: 30, count: 1 }, { type: 'BEFORE_END', min: 0, sec: 0, count: 2 }, ]; + const savedBellOptions: BellInputConfig[] = rawBellConfigData === null ? defaultBellConfig @@ -242,32 +246,30 @@ export default function TimerCreationContent({ const isNormalTimer = timerType === 'NORMAL'; const speechTypeOptions: DropdownMenuItem[] = [ - { value: 'OPENING', label: SPEECH_TYPE_RECORD['OPENING'] }, - { value: 'REBUTTAL', label: SPEECH_TYPE_RECORD['REBUTTAL'] }, - { value: 'TIMEOUT', label: SPEECH_TYPE_RECORD['TIMEOUT'] }, - { value: 'CROSS_EXAM', label: SPEECH_TYPE_RECORD['CROSS_EXAM'] }, - { value: 'CLOSING', label: SPEECH_TYPE_RECORD['CLOSING'] }, - { value: 'CUSTOM', label: SPEECH_TYPE_RECORD['CUSTOM'] }, + { value: 'OPENING', label: t(SPEECH_TYPE_RECORD['OPENING']) }, + { value: 'REBUTTAL', label: t(SPEECH_TYPE_RECORD['REBUTTAL']) }, + { value: 'TIMEOUT', label: t(SPEECH_TYPE_RECORD['TIMEOUT']) }, + { value: 'CROSS_EXAM', label: t(SPEECH_TYPE_RECORD['CROSS_EXAM']) }, + { value: 'CLOSING', label: t(SPEECH_TYPE_RECORD['CLOSING']) }, + { value: 'CUSTOM', label: t(SPEECH_TYPE_RECORD['CUSTOM']) }, ] as const; const stanceOptions: DropdownMenuItem[] = useMemo( () => [ { value: 'PROS', label: prosTeamName }, { value: 'CONS', label: consTeamName }, - { value: 'NEUTRAL', label: STANCE_RECORD['NEUTRAL'] }, + { value: 'NEUTRAL', label: t(STANCE_RECORD['NEUTRAL']) }, ], - [prosTeamName, consTeamName], - ); - const bellOptions: DropdownMenuItem[] = useMemo( - () => [ - { value: 'BEFORE_END', label: BellTypeToString['BEFORE_END'] }, - { value: 'AFTER_END', label: BellTypeToString['AFTER_END'] }, - { value: 'AFTER_START', label: BellTypeToString['AFTER_START'] }, - ], - [], + [prosTeamName, consTeamName, t], ); + const bellOptions: DropdownMenuItem[] = [ + { value: 'BEFORE_END', label: t(BELL_TYPE_LABEL_KEYS['BEFORE_END']) }, + { value: 'AFTER_END', label: t(BELL_TYPE_LABEL_KEYS['AFTER_END']) }, + { value: 'AFTER_START', label: t(BELL_TYPE_LABEL_KEYS['AFTER_START']) }, + ]; + const options = isNormalTimer ? NORMAL_OPTIONS : TIME_BASED_OPTIONS; const handleSubmit = useCallback(() => { @@ -280,7 +282,7 @@ export default function TimerCreationContent({ if (timerType === 'NORMAL') { if (totalTime <= 0) { - errors.push('발언 시간은 1초 이상이어야 해요.'); + errors.push(t('발언 시간은 1초 이상이어야 해요.')); } // 타종 옵션 유효성 검사 @@ -289,7 +291,7 @@ export default function TimerCreationContent({ const bellTime = item.min * 60 + item.sec; if (bellTime > totalTime) { - errors.push('종료 전 타종은 발언 시간보다 길 수 없어요.'); + errors.push(t('종료 전 타종은 발언 시간보다 길 수 없어요.')); } } }); @@ -297,25 +299,30 @@ export default function TimerCreationContent({ if (timerType === 'TIME_BASED') { if (totalTimePerTeam <= 0) { - errors.push('팀당 발언 시간은 1초 이상이어야 해요.'); + errors.push(t('팀당 발언 시간은 1초 이상이어야 해요.')); } if (totalTimePerSpeaking > totalTimePerTeam) { - errors.push('1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.'); + errors.push(t('1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.')); } } // SpeechType에 맞게 문자열 매핑 let speechTypeToSend: string; let stanceToSend: Stance; - if (speaker.trim().length > MAX_SPEAKER_LEN) { - errors.push(`발언자는 최대 ${MAX_SPEAKER_LEN}자까지 입력할 수 있습니다.`); + const trimmedSpeakerLength = speaker.trim().length; + if (trimmedSpeakerLength > MAX_SPEAKER_LEN) { + errors.push( + t('발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.', { + MAX_SPEAKER_LEN, + }), + ); } if (currentSpeechType === 'CUSTOM') { // 텍스트 길이 유효성 검사 if (speechTypeTextValue.length > 10) { - errors.push('발언 유형은 최대 10자까지 입력할 수 있습니다.'); + errors.push(t('발언 유형은 최대 10자까지 입력할 수 있습니다.')); } // 발언시간 유효성 검사 @@ -323,12 +330,14 @@ export default function TimerCreationContent({ timerType === 'TIME_BASED' && totalTimePerSpeaking > totalTimePerTeam ) { - errors.push('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'); + errors.push( + t('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'), + ); } // 커스텀 타이머 발언유형 유효성 검사 if (timerType === 'NORMAL' && speechTypeTextValue.trim() === '') { - errors.push('발언 유형을 입력해주세요.'); + errors.push(t('발언 유형을 입력해주세요.')); } } @@ -336,9 +345,12 @@ export default function TimerCreationContent({ alert(errors.join('\n')); return; } else { - if (currentSpeechType === 'CUSTOM') { + if (timerType === 'TIME_BASED') { + speechTypeToSend = speechTypeTextValue; + stanceToSend = 'NEUTRAL'; + } else if (currentSpeechType === 'CUSTOM') { speechTypeToSend = speechTypeTextValue; - stanceToSend = timerType === 'TIME_BASED' ? 'NEUTRAL' : stance; + stanceToSend = stance; } else { speechTypeToSend = SPEECH_TYPE_RECORD[currentSpeechType]; stanceToSend = currentSpeechType === 'TIMEOUT' ? 'NEUTRAL' : stance; @@ -362,7 +374,9 @@ export default function TimerCreationContent({ onSubmit({ stance: stanceToSend, speechType: - speechTypeToSend.trim() === '' ? '자유토론' : speechTypeToSend, + speechTypeToSend.trim() === '' + ? SPEECH_TYPE_RECORD.OPEN_DEBATE + : speechTypeToSend, boxType: timerType, time: null, timePerTeam: totalTimePerTeam, @@ -389,6 +403,7 @@ export default function TimerCreationContent({ teamSeconds, stance, speechTypeTextValue, + t, timerType, ]); @@ -438,7 +453,7 @@ export default function TimerCreationContent({ if (selectedValue === 'NEUTRAL') { if (currentSpeechType !== 'CUSTOM') { alert( - "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.", + t("중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다."), ); return; } @@ -446,7 +461,7 @@ export default function TimerCreationContent({ setStance(selectedValue); }, - [currentSpeechType], + [currentSpeechType, t], ); const handleBellExpandButtonClick = useCallback(() => { @@ -504,18 +519,14 @@ export default function TimerCreationContent({ {/* 제목 */}

- {timerType === 'NORMAL' ? '일반 타이머' : '자유토론 타이머'} + {timerType === 'NORMAL' ? t('일반 타이머') : t('자유토론 타이머')}

-

- {timerType === 'NORMAL' ? ( - '한 팀의 발언 시간이 세팅된 일반적인 타이머' - ) : ( - <> - {'팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머'} -
- {'1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감'} - - )} +

+ {timerType === 'NORMAL' + ? t('한 팀의 발언 시간이 세팅된 일반적인 타이머') + : t( + '팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감', + )}

@@ -564,7 +575,7 @@ export default function TimerCreationContent({ case 'TIMER_TYPE': return ( @@ -572,15 +583,16 @@ export default function TimerCreationContent({ id="timer-type-normal" name="timer-type" value="NORMAL" - label="일반 타이머" + label={t('일반 타이머')} checked={isNormalTimer} onChange={handleTimerChange} /> + @@ -592,7 +604,7 @@ export default function TimerCreationContent({ case 'SPEAKER': return ( setSpeaker('')} + placeholder={t('N번 토론자')} maxLength={MAX_SPEAKER_LEN} - placeholder="N번" disabled={ stance === 'NEUTRAL' || currentSpeechType === 'TIMEOUT' } @@ -615,7 +627,7 @@ export default function TimerCreationContent({ case 'TIME_NORMAL': return ( setSpeechTypeTextValue(e.target.value)} onClear={() => setSpeechTypeTextValue('')} - placeholder="주도권 토론 등" + placeholder={t('주도권 토론 등')} /> ); @@ -671,7 +683,7 @@ export default function TimerCreationContent({ case 'SPEECH_TYPE_NORMAL': return ( @@ -682,7 +694,7 @@ export default function TimerCreationContent({ options={speechTypeOptions} selectedValue={currentSpeechType} onSelect={handleSpeechTypeChange} - placeholder="선택" + placeholder={t('선택')} /> {currentSpeechType === 'CUSTOM' && ( @@ -693,7 +705,7 @@ export default function TimerCreationContent({ setSpeechTypeTextValue(e.target.value) } onClear={() => setSpeechTypeTextValue('')} - placeholder="입론, 반론, 작전 시간 등" + placeholder={t('입론, 반론, 작전 시간 등')} /> )} @@ -704,7 +716,7 @@ export default function TimerCreationContent({ case 'TEAM': return (

- 종소리 설정 + {t('종소리 설정')}

- @@ -774,6 +785,7 @@ export default function TimerCreationContent({ })); }} /> + {/* 분, 초, 타종 횟수 */} @@ -796,9 +808,10 @@ export default function TimerCreationContent({ min: getValidateTimeValue(safeValue), })); }} - placeholder="분" + placeholder={t('분')} /> - + + {t('분')} - + + {t('초')} @@ -832,8 +846,9 @@ export default function TimerCreationContent({ className="w-[60px] rounded-[4px] border border-default-border p-[8px]" value={bellInput.count} onChange={handleBellCountChange} - placeholder="횟수" + placeholder={t('횟수')} /> +
); diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx index b935ba27..76f604b6 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx @@ -18,7 +18,9 @@ export default function TimerCreationContentItem({ className, )} > -

{title}

+

+ {title} +

{children}
); diff --git a/src/page/TableComposition/hook/useTableFrom.tsx b/src/page/TableComposition/hook/useTableFrom.tsx index a82bffd5..9f3ec4ff 100644 --- a/src/page/TableComposition/hook/useTableFrom.tsx +++ b/src/page/TableComposition/hook/useTableFrom.tsx @@ -6,6 +6,12 @@ import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; import useAddDebateTable from '../../../hooks/mutations/useAddDebateTable'; import { usePutDebateTable } from '../../../hooks/mutations/usePutDebateTable'; import { isGuestFlow } from '../../../util/sessionStorage'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; const useTableFrom = ( currentStep: TableCompositionStep, @@ -13,6 +19,9 @@ const useTableFrom = ( ) => { const navigationType = useNavigationType(); const navigate = useNavigate(); + const { i18n } = useTranslation(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; // Set default value as CUSTOMIZE to prevent users to make PARLIAMENTARY tables const [formData, setFormData, removeValue] = @@ -79,7 +88,7 @@ const useTableFrom = ( const { mutate: onAddTable, isPending: isAddingTable } = useAddDebateTable( (tableId) => { removeValue(); - navigate(`/overview/customize/${tableId}`); + navigate(buildLangPath(`/overview/customize/${tableId}`, lang)); }, ); @@ -87,9 +96,9 @@ const useTableFrom = ( usePutDebateTable((tableId) => { removeValue(); if (isGuestFlow()) { - navigate(`/overview/customize/guest`); + navigate(buildLangPath(`/overview/customize/guest`, lang)); } else { - navigate(`/overview/customize/${tableId}`); + navigate(buildLangPath(`/overview/customize/${tableId}`, lang)); } }); diff --git a/src/page/TableListPage/TableListPage.tsx b/src/page/TableListPage/TableListPage.tsx index 6dc2bb18..98bb030f 100644 --- a/src/page/TableListPage/TableListPage.tsx +++ b/src/page/TableListPage/TableListPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import { Suspense } from 'react'; @@ -5,12 +6,13 @@ import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator import TableListPageContent from './components/TableListPageContent'; export default function TableListPage() { + const { t } = useTranslation(); return ( - + diff --git a/src/page/TableListPage/components/Table.tsx b/src/page/TableListPage/components/Table.tsx index e50a858e..32f1cfd3 100644 --- a/src/page/TableListPage/components/Table.tsx +++ b/src/page/TableListPage/components/Table.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { DebateTable } from '../../../type/type'; import { IoArrowForward } from 'react-icons/io5'; @@ -23,6 +24,7 @@ export default function Table({ onEdit, onClick, }: TableProps) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const { openShareModal, TableShareModal } = useTableShare(id); const { openModal, closeModal, ModalWrapper } = useModal({ @@ -57,7 +59,7 @@ export default function Table({ e.stopPropagation(); onEdit(); }} - aria-label="수정하기" + aria-label={t('수정하기')} > @@ -68,7 +70,7 @@ export default function Table({ e.stopPropagation(); openModal(); }} - aria-label="삭제하기" + aria-label={t('삭제하기')} > @@ -79,7 +81,7 @@ export default function Table({ e.stopPropagation(); openShareModal(); }} - aria-label="공유하기" + aria-label={t('공유하기')} > @@ -107,16 +109,17 @@ export default function Table({

- 주제 | {agenda} + {t('주제 | ')} + {agenda}

closeModal() }} + left={{ text: t('취소'), onClick: () => closeModal() }} right={{ - text: '삭제', + text: t('삭제'), isBold: true, onClick: () => { onDelete(); @@ -125,7 +128,9 @@ export default function Table({ }} >
-

테이블을 삭제하시겠습니까?

+

+ {t('테이블을 삭제하시겠습니까?')} +

{name}

diff --git a/src/page/TableListPage/components/TableListPageContent.tsx b/src/page/TableListPage/components/TableListPageContent.tsx index 96a376a4..fd30ed68 100644 --- a/src/page/TableListPage/components/TableListPageContent.tsx +++ b/src/page/TableListPage/components/TableListPageContent.tsx @@ -1,20 +1,34 @@ import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; import { useDeleteDebateTable } from '../../../hooks/mutations/useDeleteDebateTable'; import { useGetDebateTableList } from '../../../hooks/query/useGetDebateTableList'; import { DebateTable } from '../../../type/type'; import Table from './Table'; export default function TableListPageContent() { + const { i18n } = useTranslation(); const { data } = useGetDebateTableList(); const { mutate: deleteCustomizeTable } = useDeleteDebateTable(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; // TODO: have to delete the query param 'type' const onEdit = (tableId: number) => { - navigate(`/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`); + navigate( + buildLangPath( + `/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`, + lang, + ), + ); }; // TODO: have to delete the string 'customize' from the URL const onClick = (tableId: number) => { - navigate(`/overview/customize/${tableId}`); + navigate(buildLangPath(`/overview/customize/${tableId}`, lang)); }; const onDelete = (tableId: number) => { deleteCustomizeTable({ tableId }); @@ -24,7 +38,7 @@ export default function TableListPageContent() {
{/** Button that adds new table */} )} @@ -167,10 +181,18 @@ export default function TableOverviewPage() { disabled={isLoading} onClick={() => { if (isGuestFlow()) { - navigate(`/composition?mode=edit&type=CUSTOMIZE`); + navigate( + buildLangPath( + `/composition?mode=edit&type=CUSTOMIZE`, + lang, + ), + ); } else { navigate( - `/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`, + buildLangPath( + `/composition?mode=edit&tableId=${tableId}&type=CUSTOMIZE`, + lang, + ), ); } }} @@ -190,7 +212,7 @@ export default function TableOverviewPage() { onClick={handleStartDebate} > - 토론하기 + {t('토론하기')}
diff --git a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx index 7d89a5cb..c680b949 100644 --- a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx +++ b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import Cointoss from '../../../../assets/teamSelection/cointoss.png'; import CoinFront from '../../../../assets/teamSelection/coinfront.png'; @@ -19,6 +20,7 @@ export default function TeamSelectionModal({ initialCoinState, onCoinStateChange, }: TeamSelectionModalProps) { + const { t } = useTranslation(); const [coinState, setCoinState] = useState(initialCoinState); const hasResultSoundPlayedRef = useRef(false); @@ -104,8 +106,8 @@ export default function TeamSelectionModal({
{coinState === 'initial' && (
-

- 팀별로
동전의 앞 / 뒷면 중
하나를 선택해 주세요. +

+ {t('팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.')}

)} @@ -116,14 +118,14 @@ export default function TeamSelectionModal({
동전
- 동전 던지는 중... + {t('동전 던지는 중...')}
@@ -135,12 +137,12 @@ export default function TeamSelectionModal({
동전
- {coinState === 'front' ? '앞' : '뒤'} + {coinState === 'front' ? t('앞') : t('뒤')}
@@ -154,7 +156,7 @@ export default function TeamSelectionModal({ className="sm:text-lg sm:py-4 w-full bg-brand py-3 text-[22px] font-semibold hover:bg-brand-hover md:py-5 md:text-xl lg:py-[21px] lg:text-[22px]" onClick={() => updateCoinState('tossing')} > - 동전 던지기 + {t('동전 던지기')} )} {(coinState === 'front' || coinState === 'back') && ( @@ -163,13 +165,13 @@ export default function TeamSelectionModal({ className="sm:text-lg sm:py-4 w-full border-[2px] border-default-disabled/hover bg-default-white py-3 text-lg font-semibold hover:bg-default-disabled/hover md:py-5 md:text-xl lg:py-[21px] lg:text-[22px]" onClick={handleEdit} > - 토론 정보 수정하기 + {t('토론 정보 수정하기')} )} diff --git a/src/page/TableSharingPage/TableSharingPage.tsx b/src/page/TableSharingPage/TableSharingPage.tsx index 1ebb7c7e..a02bb51c 100644 --- a/src/page/TableSharingPage/TableSharingPage.tsx +++ b/src/page/TableSharingPage/TableSharingPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useModal } from '../../hooks/useModal'; @@ -13,6 +14,11 @@ import { PostDebateTableResponseType, } from '../../apis/responses/debateTable'; import { isGuestFlow } from '../../util/sessionStorage'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; function getDecodedDataOrNull( encodedData: string | null, @@ -44,7 +50,10 @@ function getDecodedDataOrNull( * - 로그인 상태가 아닐 경우, 비회원 플로우 실행 */ export default function TableSharingPage() { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const { openModal, closeModal, ModalWrapper } = useModal({ isCloseButtonExist: false, }); @@ -66,19 +75,19 @@ export default function TableSharingPage() { (value: PostDebateTableResponseType) => { closeModal(); sessionDebateTableRepository.deleteTable(); - navigate(`/overview/customize/${value.id}`); + navigate(buildLangPath(`/overview/customize/${value.id}`, lang)); }, // 저장 실패 시 () => { closeModal(); - throw new Error('공유받은 테이블을 저장하지 못했어요.'); + throw new Error(t('공유받은 테이블을 저장하지 못했어요.')); }, ); }, () => { // 세션 저장소에서 테이블을 불러오지 못할 때 closeModal(); - throw new Error('테이블 데이터를 확인할 수 없어요.'); + throw new Error(t('테이블 데이터를 확인할 수 없어요.')); }, ); } else { @@ -90,22 +99,22 @@ export default function TableSharingPage() { } else { // On this case, getRepository() will automatically decide what data source to use if (!decodedData) { - throw new Error('공유된 데이터가 비어 있어요.'); + throw new Error(t('공유된 데이터가 비어 있어요.')); } sessionDebateTableRepository.deleteTable(); sessionDebateTableRepository.addTable(decodedData).then( () => { // On success - navigate(`/overview/customize/guest`); + navigate(buildLangPath(`/overview/customize/guest`, lang)); }, () => { // Handling error - throw new Error('공유된 토론 테이블을 DB에 저장하지 못했어요.'); + throw new Error(t('공유된 토론 테이블을 DB에 저장하지 못했어요.')); }, ); } - }, [decodedData, navigate, openModal, closeModal, encodedData]); + }, [decodedData, navigate, openModal, closeModal, encodedData, lang]); return ( <> @@ -115,7 +124,8 @@ export default function TableSharingPage() { size={'size-24'} color={'text-brand-main'} /> -

데이터를 처리하고 있습니다...

+ +

{t('데이터를 처리하고 있습니다...')}

@@ -127,11 +137,11 @@ export default function TableSharingPage() { (value) => { closeModal(); sessionDebateTableRepository.deleteTable(); - navigate(`/overview/customize/${value.id}`); + navigate(buildLangPath(`/overview/customize/${value.id}`, lang)); }, () => { closeModal(); - throw new Error('공유받은 테이블을 저장하지 못했어요.'); + throw new Error(t('공유받은 테이블을 저장하지 못했어요.')); }, ); }} @@ -139,11 +149,11 @@ export default function TableSharingPage() { sessionDebateTableRepository.addTable(decodedData).then( () => { closeModal(); - navigate('/overview/customize/guest'); + navigate(buildLangPath('/overview/customize/guest', lang)); }, () => { closeModal(); - throw new Error('공유받은 데이터 처리에 실패했어요.'); + throw new Error(t('공유받은 데이터 처리에 실패했어요.')); }, ); }} diff --git a/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx b/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx index d87250a8..68259f08 100644 --- a/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx +++ b/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DialogModal from '../../../components/DialogModal/DialogModal'; /** @@ -20,17 +21,18 @@ export default function LoggedInStoreDBModal({ onSave, onContinue, }: LoggedInStoreDBModalProps) { + const { t } = useTranslation(); return ( onContinue() }} + left={{ text: t('비회원 상태로 토론하기'), onClick: () => onContinue() }} right={{ - text: '저장하기', + text: t('저장하기'), onClick: () => onSave(), isBold: true, }} >

- 공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요? + {t('공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?')}

); diff --git a/src/page/TimerPage/FeedbackTimerPage.tsx b/src/page/TimerPage/FeedbackTimerPage.tsx index d1f488cd..9caf9dda 100644 --- a/src/page/TimerPage/FeedbackTimerPage.tsx +++ b/src/page/TimerPage/FeedbackTimerPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useFeedbackTimer } from './hooks/useFeedbackTimer'; import FeedbackTimer from './components/FeedbackTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -5,13 +6,14 @@ import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebate import { useParams } from 'react-router-dom'; export default function FeedbackTimerPage() { + const { t } = useTranslation(); const feedbackTimerInstance = useFeedbackTimer(); const { id } = useParams(); const tableId = Number(id); // 테이블 ID 검증 로직 if (!id || isNaN(tableId)) { - throw new Error('테이블 ID가 올바르지 않습니다.'); + throw new Error(t('테이블 ID가 올바르지 않습니다.')); } return ( diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index c5efdfa5..17ea4cb9 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; @@ -19,6 +20,7 @@ import DTVolume from '../../components/icons/Volume'; import VolumeBar from '../../components/VolumeBar/VolumeBar'; export default function TimerPage() { + const { t } = useTranslation(); const pathParams = useParams(); const tableId = Number(pathParams.id); const { @@ -72,7 +74,7 @@ export default function TimerPage() { @@ -83,7 +85,7 @@ export default function TimerPage() { @@ -92,8 +94,8 @@ export default function TimerPage() { @@ -88,7 +90,7 @@ export default function CompactTimeoutTimer({ } }} > - -1분 + {t('-1분')} { @@ -97,7 +99,7 @@ export default function CompactTimeoutTimer({ } }} > - -30초 + {t('-30초')} {/* 재생 및 일시정지 버튼 */} @@ -122,12 +124,12 @@ export default function CompactTimeoutTimer({ state.setTimer((state.timer ?? 0) + 30)} > - +30초 + {t('+30초')} state.setTimer((state.timer ?? 0) + 60)} > - +1분 + {t('+1분')}
diff --git a/src/page/TimerPage/components/FeedbackTimer.tsx b/src/page/TimerPage/components/FeedbackTimer.tsx index 21981ff0..f033d1ab 100644 --- a/src/page/TimerPage/components/FeedbackTimer.tsx +++ b/src/page/TimerPage/components/FeedbackTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import CircularTimer from './CircularTimer'; @@ -10,16 +11,10 @@ interface FeedbackTimerProps { feedbackTimerInstance: FeedbackTimerLogics; } -const timeAdjustments = [ - { label: '-5분', value: -300 }, - { label: '-1분', value: -60 }, - { label: '+1분', value: 60 }, - { label: '+5분', value: 300 }, -]; - export default function FeedbackTimer({ feedbackTimerInstance, }: FeedbackTimerProps) { + const { t } = useTranslation(); const { timer, isRunning, @@ -30,6 +25,13 @@ export default function FeedbackTimer({ defaultTimer, } = feedbackTimerInstance; + const timeAdjustments = [ + { label: t('-5분'), value: -300 }, + { label: t('-1분'), value: -60 }, + { label: t('+1분'), value: 60 }, + { label: t('+5분'), value: 300 }, + ]; + const totalTime = timer ?? 0; const { minutes, seconds } = Formatting.formatSecondsToMinutes(totalTime); const minute = Formatting.formatTwoDigits(minutes); @@ -55,7 +57,9 @@ export default function FeedbackTimer({ {/* 좌측 영역 */}
{/* 제목 */} -

피드백 타이머

+

+ {t('피드백 타이머')} +

{/* 시간 조절 버튼 */}
diff --git a/src/page/TimerPage/components/FirstUseToolTip.tsx b/src/page/TimerPage/components/FirstUseToolTip.tsx index 98d5c3ba..ae830a66 100644 --- a/src/page/TimerPage/components/FirstUseToolTip.tsx +++ b/src/page/TimerPage/components/FirstUseToolTip.tsx @@ -1,3 +1,4 @@ +import { Trans, useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { LuKeyboard } from 'react-icons/lu'; import { MdOutlineTimer } from 'react-icons/md'; @@ -11,6 +12,7 @@ interface FirstUseToolTipProps { } export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) { + const { t } = useTranslation(); return (
-

자유토론 타이머 조작

+

{t('자유토론 타이머 조작')}

- 재생 버튼을 눌러 타이머를 시작 + {t('재생 버튼을 눌러 타이머를 시작')} - 타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지 + {t('타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지')} - 초기화 버튼을 눌러 타이머를 원래 시간으로 초기화 - 마우스를 사용하여 타이머를 클릭 시, 진영 변경 - 타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 - 동시에 시작 + {t('초기화 버튼을 눌러 타이머를 원래 시간으로 초기화')} + + + {t('마우스를 사용하여 타이머를 클릭 시, 진영 변경')} + + + {t( + '타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작', + )}
@@ -39,17 +46,19 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) {
-

일반 토론 타이머 조작

+

{t('일반 토론 타이머 조작')}

- 재생 버튼을 눌러 타이머를 시작 + {t('재생 버튼을 눌러 타이머를 시작')} + + {t('타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지')} + - 타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지 + {t('초기화 버튼을 눌러 타이머를 원래 시간으로 초기화')} - 초기화 버튼을 눌러 타이머를 원래 시간으로 초기화 - 작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능 + {t('작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능')}
@@ -57,34 +66,46 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) {
-

키보드 조작

+

{t('키보드 조작')}

- 스페이스 바로 타이머를 시작 및 일시정지 - R 키로 타이머 초기화 - 좌우 방향키로 이전/다음 차례로 이동 - A/L 키로 토론 진영 변경 - Enter 키로 상대 진영으로 변경 + {t('스페이스 바로 타이머를 시작 및 일시정지')} + {t('R 키로 타이머 초기화')} + {t('좌우 방향키로 이전/다음 차례로 이동')} + {t('A/L 키로 토론 진영 변경')} + {t('Enter 키로 상대 진영으로 변경')}
-

전체 화면

+

{t('전체 화면')}

- 화면 우측 상단 헤더의 전체 화면 버튼 - - 으로 활성화 + , + ]} + /> - 화면 우측 상단 헤더의 전체 화면 닫기 버튼 - - 또는 ESC 키를 눌러 전체 화면 비활성화 + , + ]} + />
@@ -95,7 +116,7 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) { className="w-fit justify-end rounded-2xl bg-neutral-50 px-6 py-2 font-bold text-neutral-900 hover:bg-neutral-300" onClick={() => onClose()} > - 닫기 + {t('닫기')}
diff --git a/src/page/TimerPage/components/LoginAndStoreModal.tsx b/src/page/TimerPage/components/LoginAndStoreModal.tsx index 71316dde..15383678 100644 --- a/src/page/TimerPage/components/LoginAndStoreModal.tsx +++ b/src/page/TimerPage/components/LoginAndStoreModal.tsx @@ -1,7 +1,13 @@ +import { useTranslation } from 'react-i18next'; import { ComponentType, ReactNode } from 'react'; import DialogModal from '../../../components/DialogModal/DialogModal'; import { oAuthLogin } from '../../../util/googleAuth'; import { useNavigate } from 'react-router-dom'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; interface LoginAndStoreModalProps { Wrapper: ComponentType<{ @@ -15,20 +21,23 @@ export function LoginAndStoreModal({ Wrapper, onClose, }: LoginAndStoreModalProps) { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; return ( { onClose(); - navigate('/overview/customize/guest'); + navigate(buildLangPath('/overview/customize/guest', lang)); }, }} right={{ - text: '네', + text: t('네'), onClick: () => { onClose(); oAuthLogin(); @@ -36,9 +45,8 @@ export function LoginAndStoreModal({ isBold: true, }} > -
- 토론을 끝내셨군요!
- 지금까지의 시간표를 로그인하고 저장할까요? +
+ {t('토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?')}
diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index c74af4f2..0ea538af 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { TimeBoxInfo } from '../../../type/type'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; @@ -7,6 +8,7 @@ import DTDebate from '../../../components/icons/Debate'; import CompactTimeoutTimer from './CompactTimeoutTimer'; import useCircularTimerAnimation from '../hooks/useCircularTimerAnimation'; import useBreakpoint from '../../../hooks/useBreakpoint'; +import { normalizeSpeechTypeKey } from '../../../util/speechType'; type NormalTimerInstance = { timer: number | null; @@ -33,6 +35,11 @@ export default function NormalTimer({ item, teamName, }: NormalTimerProps) { + const { t } = useTranslation(); + const getSpeechTypeLabel = (value: string) => { + const normalized = normalizeSpeechTypeKey(value); + return normalized ? t(normalized) : value; + }; const { timer, isAdditionalTimerOn, @@ -48,7 +55,7 @@ export default function NormalTimer({ Math.floor(Math.abs(totalTime) / 60), ); const second = Formatting.formatTwoDigits(Math.abs(totalTime % 60)); - const titleText = item.speechType; + const titleText = getSpeechTypeLabel(item.speechType); const rawProgress = timer !== null && item.time ? ((item.time - timer) / item.time) * 100 : 0; const progressMotionValue = useCircularTimerAnimation(rawProgress, isRunning); @@ -76,9 +83,9 @@ export default function NormalTimer({

- {teamName && teamName + ' 팀'} - {teamName && item.speaker && ' | '} - {item.speaker && item.speaker + ' 토론자'} + {teamName && t('{{team}} 팀', { team: teamName })} + {item.speaker && + t(' | {{speaker}} 토론자', { speaker: item.speaker })}

)} @@ -108,7 +115,7 @@ export default function NormalTimer({ }, )} > - 작전 시간 사용 + {t('작전 시간 사용')} )} diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index f72ae9dd..7384a574 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import KeyboardKeyA from '../../../assets/keyboard/keyboard_key_A.png'; @@ -33,6 +34,7 @@ export default function TimeBasedTimer({ teamName, item, }: TimeBasedTimerProps) { + const { t } = useTranslation(); const { totalTimer, speakingTimer, @@ -113,7 +115,7 @@ export default function TimeBasedTimer({ {prosCons @@ -138,7 +140,7 @@ export default function TimeBasedTimer({ {speakingTimer !== null && (

- 전체 시간 + {t('전체 시간')}

@@ -159,7 +161,7 @@ export default function TimeBasedTimer({ { 'bg-camp-red': prosCons === 'CONS' }, )} > - 현재 시간 + {t('현재 시간')}

diff --git a/src/page/TimerPage/components/TimerController.tsx b/src/page/TimerPage/components/TimerController.tsx index 87765264..429b6c5e 100644 --- a/src/page/TimerPage/components/TimerController.tsx +++ b/src/page/TimerPage/components/TimerController.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { GiPauseButton } from 'react-icons/gi'; import DTReset from '../../../components/icons/Reset'; import DTPlay from '../../../components/icons/Play'; @@ -21,6 +22,7 @@ export default function TimerController({ stance, boxType, }: TimerControllerProps) { + const { t } = useTranslation(); const bgClass = boxType === 'FEEDBACK' ? 'bg-brand' @@ -34,7 +36,7 @@ export default function TimerController({ {/* 초기화 버튼 */}

diff --git a/src/page/VoteParticipationPage/VoteParticipationPage.tsx b/src/page/VoteParticipationPage/VoteParticipationPage.tsx index c786aabc..5341ecde 100644 --- a/src/page/VoteParticipationPage/VoteParticipationPage.tsx +++ b/src/page/VoteParticipationPage/VoteParticipationPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import clsx from 'clsx'; @@ -11,15 +12,18 @@ import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; import usePostVoterPollInfo from '../../hooks/mutations/usePostVoterPollInfo'; import { TeamKey } from '../../type/type'; - -const TEAM_LABEL = { - PROS: '찬성팀', - CONS: '반대팀', -} as const; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; export default function VoteParticipationPage() { + const { t, i18n } = useTranslation(); const { id: pollIdParam } = useParams(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; // 1) pollId 파싱 + 유효성 체크 const pollId = pollIdParam ? Number(pollIdParam) : NaN; const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); @@ -40,7 +44,9 @@ export default function VoteParticipationPage() { const isSubmitDisabled = participantName.trim().length === 0 || selectedTeam === null; - const { mutate } = usePostVoterPollInfo(() => navigate(`/vote/end`)); + const { mutate } = usePostVoterPollInfo(() => + navigate(buildLangPath('/vote/end', lang)), + ); const handleSubmit = () => { if (isSubmitDisabled) return; @@ -71,8 +77,10 @@ export default function VoteParticipationPage() { return ( - navigate('/')}> - 유효하지 않은 투표 링크입니다. + navigate(buildLangPath('/', lang))} + > + {t('유효하지 않은 투표 링크입니다.')} @@ -88,7 +96,7 @@ export default function VoteParticipationPage() {

- 승패투표 + {t('승패투표')}

@@ -98,7 +106,7 @@ export default function VoteParticipationPage() { htmlFor="participant-name" className="sm:text-xl whitespace-nowrap text-lg font-semibold text-default-black" > - 참여자 : + {t('참여자 :')} setSelectedTeam('PROS')} /> + - {'투표완료'} + {t('투표완료')} closeModal(), isBold: true, }} right={{ - text: '제출하기', + text: t('제출하기'), onClick: () => { handleSubmit(); closeModal(); @@ -163,8 +172,12 @@ export default function VoteParticipationPage() { }} >
-

투표를 제출하시겠습니까?

-

(제출 후에는 변경이 불가능 합니다.)

+

+ {t('투표를 제출하시겠습니까?')} +

+

+ {t('(제출 후에는 변경이 불가능 합니다.)')} +

diff --git a/src/routes/LanguageWrapper.tsx b/src/routes/LanguageWrapper.tsx index cf7dc113..ec14426d 100644 --- a/src/routes/LanguageWrapper.tsx +++ b/src/routes/LanguageWrapper.tsx @@ -1,20 +1,43 @@ import { useEffect } from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import i18n from '../i18n'; - -const supportedLangs = ['ko', 'en']; +import { + DEFAULT_LANG, + buildLangPath, + getSelectedLangFromRoute, + isSupportedLang, + stripDefaultLangFromPath, +} from '../util/languageRouting'; export default function LanguageWrapper() { const { lang } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); useEffect(() => { - // URL에 lang 파라미터가 없으면 'ko'를 기본값으로 사용 - const currentLang = lang || 'ko'; + const selectedLang = getSelectedLangFromRoute(lang, location.pathname); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + + if (lang === DEFAULT_LANG) { + const nextPath = stripDefaultLangFromPath(location.pathname); + const nextUrl = `${nextPath || '/'}${location.search}${location.hash}`; + navigate(nextUrl, { replace: true }); + return; + } + + if (!lang && isSupportedLang(currentLang) && currentLang !== DEFAULT_LANG) { + const nextPath = buildLangPath(location.pathname, currentLang); + if (nextPath !== location.pathname) { + const nextUrl = `${nextPath}${location.search}${location.hash}`; + navigate(nextUrl, { replace: true }); + return; + } + } - if (supportedLangs.includes(currentLang) && i18n.language !== currentLang) { - i18n.changeLanguage(currentLang); + if (isSupportedLang(selectedLang) && i18n.language !== selectedLang) { + i18n.changeLanguage(selectedLang); } - }, [lang]); + }, [lang, location.hash, location.pathname, location.search, navigate]); return ; } diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx index 4f9b08c2..e2372309 100644 --- a/src/routes/ProtectedRoute.tsx +++ b/src/routes/ProtectedRoute.tsx @@ -1,17 +1,26 @@ import { Navigate, useLocation } from 'react-router-dom'; - import { PropsWithChildren } from 'react'; +import { useTranslation } from 'react-i18next'; import { getAccessToken } from '../util/accessToken'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../util/languageRouting'; export default function ProtectedRoute(props: PropsWithChildren) { const { children } = props; + const { i18n } = useTranslation(); const isAuthenticated = getAccessToken() || false; const location = useLocation(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const homePath = buildLangPath('/home', lang); return isAuthenticated ? ( children ) : ( - + ); } diff --git a/src/util/arrayEncoding.ts b/src/util/arrayEncoding.ts index a4fca303..515d4db1 100644 --- a/src/util/arrayEncoding.ts +++ b/src/util/arrayEncoding.ts @@ -21,11 +21,14 @@ export function decodeDebateTableData(encodedData: string): DebateTableData { } export function createTableShareUrl( - baseUrl: string, + baseUrl: string | undefined, data: DebateTableData, ): string { const encoded = encodeDebateTableData(data); - return `${baseUrl}/share?data=${encoded}`; + const resolvedBaseUrl = + baseUrl && baseUrl.trim() !== '' ? baseUrl : window.location.origin; + const normalizedBaseUrl = resolvedBaseUrl.replace(/\/+$/, ''); + return `${normalizedBaseUrl}/share?data=${encoded}`; } export function extractTableShareUrl(url: string): DebateTableData | null { diff --git a/src/util/languageRouting.ts b/src/util/languageRouting.ts new file mode 100644 index 00000000..901ce4e0 --- /dev/null +++ b/src/util/languageRouting.ts @@ -0,0 +1,57 @@ +const SUPPORTED_LANGS = ['ko', 'en'] as const; +const DEFAULT_LANG = 'ko'; + +type SupportedLang = (typeof SUPPORTED_LANGS)[number]; + +const isSupportedLang = (value?: string): value is SupportedLang => + !!value && SUPPORTED_LANGS.includes(value as SupportedLang); + +const getLangFromPath = (pathname: string): SupportedLang | undefined => { + const pathSegments = pathname.split('/'); + return isSupportedLang(pathSegments[1]) ? pathSegments[1] : undefined; +}; + +const getSelectedLang = (langParam?: string): SupportedLang => + isSupportedLang(langParam) ? langParam : DEFAULT_LANG; + +const getSelectedLangFromRoute = ( + langParam: string | undefined, + pathname: string, +): SupportedLang => getSelectedLang(langParam ?? getLangFromPath(pathname)); + +const stripDefaultLangFromPath = (pathname: string): string => { + const updated = pathname.replace(new RegExp(`^/${DEFAULT_LANG}(?=/|$)`), '/'); + return updated === '/' ? updated : updated.replace(/\/+$/, ''); +}; + +const buildLangPath = (pathname: string, lang: SupportedLang): string => { + const pathSegments = pathname.split('/'); + const hasLangSegment = + pathSegments.length > 1 && isSupportedLang(pathSegments[1]); + + if (lang === DEFAULT_LANG) { + if (hasLangSegment) { + pathSegments.splice(1, 1); + return pathSegments.join('/') || '/'; + } + return pathname; + } + + if (hasLangSegment) { + pathSegments[1] = lang; + return pathSegments.join('/'); + } + + return `/${lang}${pathname === '/' ? '' : pathname}`; +}; + +export { + SUPPORTED_LANGS, + DEFAULT_LANG, + isSupportedLang, + getLangFromPath, + getSelectedLang, + getSelectedLangFromRoute, + stripDefaultLangFromPath, + buildLangPath, +}; diff --git a/src/util/speechType.ts b/src/util/speechType.ts new file mode 100644 index 00000000..5f78f1a0 --- /dev/null +++ b/src/util/speechType.ts @@ -0,0 +1,29 @@ +export type SpeechTypeKey = + | 'OPENING' + | 'REBUTTAL' + | 'TIMEOUT' + | 'CLOSING' + | 'CROSS_EXAM' + | 'CUSTOM' + | 'OPEN_DEBATE'; + +export const SPEECH_TYPE_RECORD: Record = { + OPENING: '입론', + CLOSING: '최종발언', + CUSTOM: '직접 입력', + REBUTTAL: '반론', + CROSS_EXAM: '교차조사', + TIMEOUT: '작전시간', + OPEN_DEBATE: '자유토론', +} as const; + +const normalize = (value: string) => value.replace(/\s+/g, '').trim(); + +const SPEECH_TYPE_LABEL_BY_NORMALIZED = new Map( + Object.values(SPEECH_TYPE_RECORD).map((label) => [normalize(label), label]), +); + +export const normalizeSpeechTypeKey = (value: string): string | null => { + const compact = normalize(value); + return SPEECH_TYPE_LABEL_BY_NORMALIZED.get(compact) ?? null; +}; diff --git a/src/util/validateUserAgent.ts b/src/util/validateUserAgent.ts deleted file mode 100644 index e0d44b38..00000000 --- a/src/util/validateUserAgent.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const isEmbeddedWebView = (): boolean => { - const userAgent = navigator.userAgent.toLowerCase(); - // console.log(userAgent); - - return /fban|fbav|instagram|kakaotalk|line|wechat|snapchat|twitter/i.test( - userAgent, - ); -};