diff --git a/.cli/docs/architecture.md b/.cli/docs/architecture.md index 5a6c633..97b102b 100644 --- a/.cli/docs/architecture.md +++ b/.cli/docs/architecture.md @@ -71,6 +71,18 @@ - `/boarding` 라우트: 딥링크 호환을 위해 동일 form/model 재사용 - 메모/노트 입력은 탑승 생성 경로에서 제거됨 +## I18n 소유권 (1단계) + +- 지원 언어/기본값/카피 상수: `shared/config/i18n.ts` +- 초기 언어 결정/수동 고정 저장: `features/i18n/model/resolveInitialLocale.ts` +- 런타임 번역 접근(context/hook): `features/i18n/model/useI18n.tsx` +- 앱 초기 bootstrap + 수동 변경 UI(헤더 select): `features/i18n/ui/I18nLayoutShell.tsx` +- `app/layout.tsx`는 i18n shell을 마운트해 단일 URL에서 언어 상태만 관리한다 +- 우선순위: `수동 저장값(localStorage) > 브라우저 언어 > en` +- 지원 외 언어는 `en`으로 폴백한다 +- 페이지/UI 문구는 key(`lobby.*`, `flight.*`, `debrief.*`, `log.*`, `settings.*`, `routes.*`)로 관리한다 +- 항로 메타(`shared/config/routes.ts`)는 사용자 노출 문자열 대신 i18n key를 소유한다 + ## 변경 정책 - 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다 diff --git a/src/app/boarding/page.tsx b/src/app/boarding/page.tsx index 1cd2305..ef28467 100644 --- a/src/app/boarding/page.tsx +++ b/src/app/boarding/page.tsx @@ -4,15 +4,18 @@ import { Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { ROUTES } from '@/shared/config/routes'; import { BoardingMissionForm, startVoyage } from '@/features/boarding'; +import { useI18n } from '@/features/i18n/model/useI18n'; function BoardingContent() { + const { t } = useI18n(); const router = useRouter(); const searchParams = useSearchParams(); const routeId = searchParams.get('routeId'); const route = ROUTES.find(r => r.id === routeId) || ROUTES[0]; + const routeName = t(route.nameKey, undefined, route.id); const handleDocking = (mission: string) => { - const started = startVoyage({ route, mission }); + const started = startVoyage({ route, mission, routeName }); if (!started) return; router.push('/flight'); }; @@ -20,8 +23,12 @@ function BoardingContent() { return (
-

Boarding Check

-

{route.name} 항로 탑승

+

+ {t('boarding.check')} +

+

+ {t('boarding.routeBoarding', { routeName })} +

@@ -35,8 +42,10 @@ function BoardingContent() { } export default function BoardingPage() { + const { t } = useI18n(); + return ( - Loading...
}> + {t('common.loading')}
}> ); diff --git a/src/app/debrief/page.tsx b/src/app/debrief/page.tsx index 151a433..ece6976 100644 --- a/src/app/debrief/page.tsx +++ b/src/app/debrief/page.tsx @@ -2,10 +2,13 @@ import { FormEvent, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { useI18n } from '@/features/i18n/model/useI18n'; +import { DEBRIEF_STATUS_OPTIONS } from '@/shared/config/i18n'; import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store'; import { Voyage, VoyageStatus } from '@/shared/types'; export default function DebriefPage() { + const { t } = useI18n(); const router = useRouter(); const [voyage, setVoyage] = useState(null); @@ -43,54 +46,58 @@ export default function DebriefPage() { if (!voyage) return null; - const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [ - { value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' }, - { value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' }, - { value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' }, - ]; + const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({ + value: option.value as VoyageStatus, + label: t(option.labelKey), + desc: t(option.descKey), + })); return (
-

무사히 궤도에 도착했습니다

-

이번 항해를 짧게 기록하고 마무리하세요.

+

{t('debrief.page.title')}

+

{t('debrief.page.description')}

{/* Question 1: Status */}
-
+
{statusOptions.map((opt) => ( ))}
- {/* Question 2: Secured */} + {/* Question 2: Reflection */}
setProgress(e.target.value)} - placeholder="예: 기획안 목차 구성 완료" + placeholder={t('debrief.reflection.placeholder')} className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all" />
@@ -100,7 +107,7 @@ export default function DebriefPage() { disabled={!status} className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20" > - 항해일지 저장 + {t('debrief.save')}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d269a6c..75ed4aa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import Link from "next/link"; +import { I18nLayoutShell } from "@/features/i18n/ui/I18nLayoutShell"; export const metadata: Metadata = { title: "Focustella", @@ -13,23 +13,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {/* Layout Container */} -
-
- - FOCUSTELLA - - -
-
- {children} -
-
+ {children} ); diff --git a/src/app/log/[id]/page.tsx b/src/app/log/[id]/page.tsx index 888cb58..3fff774 100644 --- a/src/app/log/[id]/page.tsx +++ b/src/app/log/[id]/page.tsx @@ -1,15 +1,17 @@ 'use client'; import { useEffect, useState, use } from 'react'; -import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { useI18n } from '@/features/i18n/model/useI18n'; +import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n'; +import { findRouteById } from '@/shared/config/routes'; import { getHistory } from '@/shared/lib/store'; import { Voyage } from '@/shared/types'; export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { t } = useI18n(); // Next.js 15: params is a Promise const resolvedParams = use(params); - const router = useRouter(); const [log, setLog] = useState(null); useEffect(() => { @@ -18,47 +20,49 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string if (found) setLog(found); }, [resolvedParams.id]); - if (!log) return
Loading or Not Found...
; + if (!log) { + return
{t('log.detail.loadingOrNotFound')}
; + } + + const route = findRouteById(log.routeId); + const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName; + const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress')); return (
- ← 목록으로 + + ← {t('log.detail.back')} +

{log.missionText}

{new Date(log.startedAt).toLocaleString()} - {log.routeName} ({log.durationMinutes}m) + {routeName} ({log.durationMinutes}{t('common.minuteShort')})
-

결과 상태

-

- {log.status === 'completed' && '✅ 계획대로'} - {log.status === 'partial' && '🌓 부분 진행'} - {log.status === 'reoriented' && '🧭 방향 재설정'} - {log.status === 'aborted' && '🚨 조기 귀환'} - {log.status === 'in_progress' && '진행 중'} -

+

{t('log.detail.statusTitle')}

+

{statusLabel}

-

확보한 것

+

{t('log.detail.progressTitle')}

{log.debriefProgress || '-'}

-

다음 행동

+

{t('log.detail.nextActionTitle')}

{log.nextAction || '-'}

{log.notes && (
-

초기 메모

+

{t('log.detail.initialNoteTitle')}

"{log.notes}"

)} diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx index 1460232..75a3987 100644 --- a/src/app/log/page.tsx +++ b/src/app/log/page.tsx @@ -2,10 +2,14 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; +import { useI18n } from '@/features/i18n/model/useI18n'; +import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n'; +import { findRouteById } from '@/shared/config/routes'; import { getHistory } from '@/shared/lib/store'; import { Voyage, VoyageStatus } from '@/shared/types'; export default function LogListPage() { + const { t } = useI18n(); const [logs, setLogs] = useState([]); useEffect(() => { @@ -13,48 +17,52 @@ export default function LogListPage() { }, []); const getStatusLabel = (s: VoyageStatus) => { - switch(s) { - case 'completed': return '✅ 계획대로'; - case 'partial': return '🌓 부분 진행'; - case 'reoriented': return '🧭 방향 재설정'; - case 'aborted': return '🚨 조기 귀환'; - default: return '진행 중'; - } + return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress')); }; return (
-

나의 항해 기록

+

{t('log.title')}

{logs.length === 0 ? (
-

아직 기록된 항해가 없습니다.

+

{t('log.empty')}

- 첫 항해 떠나기 + {t('log.firstVoyage')}
) : (
- {logs.map((log) => ( - -
- - {new Date(log.startedAt).toLocaleDateString()} - - - {getStatusLabel(log.status)} - -
-

- {log.missionText} -

-

{log.routeName} · {log.durationMinutes}min

- - ))} + {logs.map((log) => { + const route = findRouteById(log.routeId); + const routeName = route + ? t(route.nameKey, undefined, log.routeName) + : log.routeName; + + return ( + +
+ + {new Date(log.startedAt).toLocaleDateString()} + + + {getStatusLabel(log.status)} + +
+

+ {log.missionText} +

+

+ {routeName} · {log.durationMinutes} + {t('common.minuteShort')} +

+ + ); + })}
)}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e08970e..e84729c 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,9 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useI18n } from '@/features/i18n/model/useI18n'; import { getPreferences, savePreferences } from '@/shared/lib/store'; export default function SettingsPage() { + const { t } = useI18n(); const [hideSeconds, setHideSeconds] = useState(false); useEffect(() => { @@ -19,13 +21,13 @@ export default function SettingsPage() { return (
-

설정

+

{t('settings.title')}

-

초 단위 숨기기

-

타이머에서 분 단위만 표시하여 불안감을 줄입니다.

+

{t('settings.hideSeconds.title')}

+

{t('settings.hideSeconds.description')}

)}
diff --git a/src/features/i18n/model/resolveInitialLocale.ts b/src/features/i18n/model/resolveInitialLocale.ts new file mode 100644 index 0000000..742873b --- /dev/null +++ b/src/features/i18n/model/resolveInitialLocale.ts @@ -0,0 +1,42 @@ +import { + DEFAULT_LOCALE, + Locale, + MANUAL_LOCALE_STORAGE_KEY, + SUPPORTED_LOCALES, +} from "@/shared/config/i18n"; + +const normalizeLocale = (raw: string | null | undefined): Locale | null => { + if (!raw) return null; + const base = raw.trim().toLowerCase().split("-")[0]; + + return SUPPORTED_LOCALES.includes(base as Locale) ? (base as Locale) : null; +}; + +export const getManualLocale = (): Locale | null => { + if (typeof window === "undefined") return null; + return normalizeLocale(localStorage.getItem(MANUAL_LOCALE_STORAGE_KEY)); +}; + +const getBrowserLocale = (): Locale | null => { + if (typeof navigator === "undefined") return null; + const candidates = [...(navigator.languages ?? []), navigator.language]; + + for (const candidate of candidates) { + const locale = normalizeLocale(candidate); + if (locale) return locale; + } + + return null; +}; + +export const resolveInitialLocale = (): Locale => { + const manualLocale = getManualLocale(); + if (manualLocale) return manualLocale; + + return getBrowserLocale() ?? DEFAULT_LOCALE; +}; + +export const saveManualLocale = (locale: Locale) => { + if (typeof window === "undefined") return; + localStorage.setItem(MANUAL_LOCALE_STORAGE_KEY, locale); +}; diff --git a/src/features/i18n/model/useI18n.tsx b/src/features/i18n/model/useI18n.tsx new file mode 100644 index 0000000..731f192 --- /dev/null +++ b/src/features/i18n/model/useI18n.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { ReactNode, createContext, useContext } from "react"; + +import { + DEFAULT_LOCALE, + I18nKey, + Locale, + TranslationParams, + translateText, +} from "@/shared/config/i18n"; + +type I18nContextValue = { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: I18nKey | string, params?: TranslationParams, fallback?: string) => string; +}; + +const I18nContext = createContext({ + locale: DEFAULT_LOCALE, + setLocale: () => {}, + t: (key, params, fallback) => + translateText(DEFAULT_LOCALE, key, params, fallback), +}); + +export function I18nProvider({ + children, + locale, + setLocale, +}: { + children: ReactNode; + locale: Locale; + setLocale: (locale: Locale) => void; +}) { + const t = (key: I18nKey | string, params?: TranslationParams, fallback?: string) => + translateText(locale, key, params, fallback); + + return ( + + {children} + + ); +} + +export const useI18n = () => useContext(I18nContext); diff --git a/src/features/i18n/ui/I18nLayoutShell.tsx b/src/features/i18n/ui/I18nLayoutShell.tsx new file mode 100644 index 0000000..fd920fa --- /dev/null +++ b/src/features/i18n/ui/I18nLayoutShell.tsx @@ -0,0 +1,87 @@ +"use client"; + +import Link from "next/link"; +import { ChangeEvent, useEffect, useState } from "react"; + +import { + DEFAULT_LOCALE, + I18nKey, + LOCALE_LABELS, + Locale, + SUPPORTED_LOCALES, + translateText, +} from "@/shared/config/i18n"; +import { + resolveInitialLocale, + saveManualLocale, +} from "@/features/i18n/model/resolveInitialLocale"; +import { I18nProvider } from "@/features/i18n/model/useI18n"; + +export function I18nLayoutShell({ + children, +}: { + children: React.ReactNode; +}) { + const [locale, setLocale] = useState(DEFAULT_LOCALE); + + useEffect(() => { + const initialLocale = resolveInitialLocale(); + setLocale(initialLocale); + document.documentElement.lang = initialLocale; + }, []); + + const handleSetLocale = (nextLocale: Locale) => { + if (!SUPPORTED_LOCALES.includes(nextLocale)) return; + setLocale(nextLocale); + saveManualLocale(nextLocale); + document.documentElement.lang = nextLocale; + }; + + const handleLocaleChange = (event: ChangeEvent) => { + const nextLocale = event.target.value as Locale; + handleSetLocale(nextLocale); + }; + + const t = (key: I18nKey) => translateText(locale, key); + + return ( + +
+
+ + FOCUSTELLA + + +
+
{children}
+
+
+ ); +} diff --git a/src/shared/config/i18n.ts b/src/shared/config/i18n.ts new file mode 100644 index 0000000..a3dcd73 --- /dev/null +++ b/src/shared/config/i18n.ts @@ -0,0 +1,297 @@ +import { VoyageStatus } from "@/shared/types"; + +export const SUPPORTED_LOCALES = ["ko", "en", "ja"] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = "en"; + +export const MANUAL_LOCALE_STORAGE_KEY = "focustella_locale_manual_v1"; + +export const LOCALE_LABELS: Record = { + ko: "한국어", + en: "English", + ja: "日本語", +}; + +const enMessages = { + "layout.nav.log": "Logbook", + "layout.nav.settings": "Settings", + "layout.nav.language": "Language", + "common.loading": "Loading...", + "common.minuteShort": "min", + "routes.station.name": "Space Station", + "routes.station.tag": "Wait/Flexible", + "routes.station.description": "A safe zone you can stay in without time limits", + "routes.orion.name": "Orion", + "routes.orion.tag": "Deep Work", + "routes.orion.description": "60-minute focus voyage", + "routes.gemini.name": "Gemini", + "routes.gemini.tag": "Short Sprint", + "routes.gemini.description": "30-minute focus voyage", + "lobby.title": "Which constellation will you sail to?", + "lobby.subtitle": "Choose an orbit that helps your focus.", + "lobby.cta.station": "Enter Station (Wait)", + "lobby.cta.launch": "Launch Now", + "lobby.modal.boardingCheck": "Boarding Check", + "lobby.modal.routeBoarding": "{routeName} Route Boarding", + "lobby.modal.description": "Set your mission before starting this voyage.", + "boarding.check": "Boarding Check", + "boarding.routeBoarding": "{routeName} Route Boarding", + "boarding.missionLabel": "Core mission for this voyage", + "boarding.missionPlaceholder": "e.g. Finish 3 intro paragraphs", + "boarding.cancel": "Cancel", + "boarding.submit": "Dock Complete (Launch)", + "flight.badge.paused": "Paused", + "flight.badge.cruising": "Cruising", + "flight.missionLabel": "Voyage Mission", + "flight.pause": "Pause", + "flight.resume": "Resume", + "flight.finish.debrief": "Arrived (Debrief)", + "flight.finish.end": "End Voyage", + "flight.debrief.title": "Wrap up this voyage", + "flight.debrief.description": "Write a short note and save it to your logbook.", + "debrief.page.title": "You safely reached orbit", + "debrief.page.description": "Record this voyage briefly and wrap up.", + "debrief.status.label": "Voyage Result", + "debrief.option.completed.label": "Completed the mission", + "debrief.option.completed.desc": "I finished what I set out to do.", + "debrief.option.partial.label": "Made partial progress", + "debrief.option.partial.desc": "I advanced key parts and left next steps.", + "debrief.option.reoriented.label": "Redefined the mission", + "debrief.option.reoriented.desc": "I reset scope and priorities while working.", + "debrief.reflection.label": "Reflection after this voyage", + "debrief.reflection.placeholder": + "e.g. What worked, what did not, and what I learned", + "debrief.save": "Save to Logbook", + "log.title": "My Voyage Logs", + "log.empty": "No voyages recorded yet.", + "log.firstVoyage": "Start your first voyage", + "log.detail.back": "Back to list", + "log.detail.loadingOrNotFound": "Loading or Not Found...", + "log.detail.statusTitle": "Result Status", + "log.detail.progressTitle": "What I secured", + "log.detail.nextActionTitle": "Next Action", + "log.detail.initialNoteTitle": "Initial Note", + "settings.title": "Settings", + "settings.hideSeconds.title": "Hide Seconds", + "settings.hideSeconds.description": + "Show only minutes on the timer to reduce pressure.", + "status.completed": "Completed", + "status.partial": "Partial", + "status.reoriented": "Reoriented", + "status.aborted": "Aborted Early", + "status.in_progress": "In Progress", +} as const; + +type I18nMessages = typeof enMessages; + +const koMessages: I18nMessages = { + "layout.nav.log": "항해일지", + "layout.nav.settings": "설정", + "layout.nav.language": "언어", + "common.loading": "로딩 중...", + "common.minuteShort": "분", + "routes.station.name": "우주정거장", + "routes.station.tag": "대기/자유", + "routes.station.description": "시간 제한 없이 머무를 수 있는 안전지대", + "routes.orion.name": "오리온", + "routes.orion.tag": "딥워크", + "routes.orion.description": "60분 집중 항해", + "routes.gemini.name": "쌍둥이자리", + "routes.gemini.tag": "숏스프린트", + "routes.gemini.description": "30분 집중 항해", + "lobby.title": "어느 별자리로 출항할까요?", + "lobby.subtitle": "몰입하기 좋은 궤도입니다.", + "lobby.cta.station": "정거장 진입 (대기)", + "lobby.cta.launch": "바로 출항", + "lobby.modal.boardingCheck": "Boarding Check", + "lobby.modal.routeBoarding": "{routeName} 항로 탑승", + "lobby.modal.description": "항해를 시작하기 전에 목표를 설정하세요.", + "boarding.check": "Boarding Check", + "boarding.routeBoarding": "{routeName} 항로 탑승", + "boarding.missionLabel": "이번 항해의 핵심 목표", + "boarding.missionPlaceholder": "예: 서론 3문단 완성하기", + "boarding.cancel": "취소", + "boarding.submit": "도킹 완료 (출항)", + "flight.badge.paused": "일시정지", + "flight.badge.cruising": "순항 중", + "flight.missionLabel": "이번 항해 목표", + "flight.pause": "일시정지", + "flight.resume": "다시 시작", + "flight.finish.debrief": "도착 (회고)", + "flight.finish.end": "항해 종료", + "flight.debrief.title": "이번 항해를 정리하세요", + "flight.debrief.description": "짧게 기록하고 항해일지에 저장합니다.", + "debrief.page.title": "무사히 궤도에 도착했습니다", + "debrief.page.description": "이번 항해를 짧게 기록하고 마무리하세요.", + "debrief.status.label": "항해 결과", + "debrief.option.completed.label": "목표를 완수했어요", + "debrief.option.completed.desc": "시작할 때 정한 목표를 끝까지 해냈어요", + "debrief.option.partial.label": "일부까지 진행했어요", + "debrief.option.partial.desc": "중요한 부분을 진행했고 다음 단계가 남았어요", + "debrief.option.reoriented.label": "목표를 재정의했어요", + "debrief.option.reoriented.desc": + "진행 중 목표 범위와 우선순위를 다시 정했어요", + "debrief.reflection.label": "이번 항해를 마치고 느낀 점", + "debrief.reflection.placeholder": + "예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요", + "debrief.save": "항해일지 저장", + "log.title": "나의 항해 기록", + "log.empty": "아직 기록된 항해가 없습니다.", + "log.firstVoyage": "첫 항해 떠나기", + "log.detail.back": "목록으로", + "log.detail.loadingOrNotFound": "로딩 중이거나 기록이 없습니다.", + "log.detail.statusTitle": "결과 상태", + "log.detail.progressTitle": "확보한 것", + "log.detail.nextActionTitle": "다음 행동", + "log.detail.initialNoteTitle": "초기 메모", + "settings.title": "설정", + "settings.hideSeconds.title": "초 단위 숨기기", + "settings.hideSeconds.description": + "타이머에서 분 단위만 표시하여 불안감을 줄입니다.", + "status.completed": "✅ 계획대로", + "status.partial": "🌓 부분 진행", + "status.reoriented": "🧭 방향 재설정", + "status.aborted": "🚨 조기 귀환", + "status.in_progress": "진행 중", +}; + +const jaMessages: I18nMessages = { + "layout.nav.log": "航海ログ", + "layout.nav.settings": "設定", + "layout.nav.language": "言語", + "common.loading": "読み込み中...", + "common.minuteShort": "分", + "routes.station.name": "宇宙ステーション", + "routes.station.tag": "待機/自由", + "routes.station.description": "時間制限なしで滞在できる安全地帯", + "routes.orion.name": "オリオン", + "routes.orion.tag": "ディープワーク", + "routes.orion.description": "60分集中航海", + "routes.gemini.name": "ふたご座", + "routes.gemini.tag": "ショートスプリント", + "routes.gemini.description": "30分集中航海", + "lobby.title": "どの星座へ航海しますか?", + "lobby.subtitle": "集中しやすい軌道を選びましょう。", + "lobby.cta.station": "ステーションへ入る(待機)", + "lobby.cta.launch": "今すぐ出航", + "lobby.modal.boardingCheck": "搭乗チェック", + "lobby.modal.routeBoarding": "{routeName} 航路に搭乗", + "lobby.modal.description": "航海を始める前に目標を設定してください。", + "boarding.check": "搭乗チェック", + "boarding.routeBoarding": "{routeName} 航路に搭乗", + "boarding.missionLabel": "今回の航海のコア目標", + "boarding.missionPlaceholder": "例: 導入の3段落を完成する", + "boarding.cancel": "キャンセル", + "boarding.submit": "ドッキング完了(出航)", + "flight.badge.paused": "一時停止", + "flight.badge.cruising": "巡航中", + "flight.missionLabel": "今回の航海目標", + "flight.pause": "一時停止", + "flight.resume": "再開", + "flight.finish.debrief": "到着(振り返り)", + "flight.finish.end": "航海終了", + "flight.debrief.title": "今回の航海を整理しましょう", + "flight.debrief.description": "短く記録して航海ログに保存します。", + "debrief.page.title": "無事に軌道へ到着しました", + "debrief.page.description": "今回の航海を短く記録して締めくくりましょう。", + "debrief.status.label": "航海結果", + "debrief.option.completed.label": "目標を達成しました", + "debrief.option.completed.desc": "開始時に決めた目標を最後まで完了しました。", + "debrief.option.partial.label": "一部まで進めました", + "debrief.option.partial.desc": "重要な部分を進め、次の段階が残っています。", + "debrief.option.reoriented.label": "目標を再定義しました", + "debrief.option.reoriented.desc": + "進行中に目標範囲と優先順位を見直しました。", + "debrief.reflection.label": "今回の航海を終えて感じたこと", + "debrief.reflection.placeholder": + "例: 良かった点・難しかった点・気づきを短く残してください", + "debrief.save": "航海ログに保存", + "log.title": "私の航海記録", + "log.empty": "まだ記録された航海がありません。", + "log.firstVoyage": "最初の航海を始める", + "log.detail.back": "一覧へ戻る", + "log.detail.loadingOrNotFound": "読み込み中、または記録が見つかりません。", + "log.detail.statusTitle": "結果ステータス", + "log.detail.progressTitle": "確保できたこと", + "log.detail.nextActionTitle": "次の行動", + "log.detail.initialNoteTitle": "初期メモ", + "settings.title": "設定", + "settings.hideSeconds.title": "秒表示を隠す", + "settings.hideSeconds.description": + "タイマーを分表示のみにしてプレッシャーを減らします。", + "status.completed": "✅ 計画どおり", + "status.partial": "🌓 一部進行", + "status.reoriented": "🧭 方針再設定", + "status.aborted": "🚨 早期帰還", + "status.in_progress": "進行中", +}; + +export const I18N_MESSAGES: Record = { + ko: koMessages, + en: enMessages, + ja: jaMessages, +}; + +export type I18nKey = keyof I18nMessages; + +export type TranslationParams = Record; + +const interpolateMessage = ( + template: string, + params?: TranslationParams, +): string => { + if (!params) return template; + + return template.replace(/\{(\w+)\}/g, (_, paramKey) => { + const value = params[paramKey]; + return value === undefined ? "" : String(value); + }); +}; + +export const translateText = ( + locale: Locale, + key: I18nKey | string, + params?: TranslationParams, + fallback = "", +): string => { + const localeMessages = I18N_MESSAGES[locale] as Record; + const defaultMessages = I18N_MESSAGES[DEFAULT_LOCALE] as Record; + const template = + localeMessages[key] ?? defaultMessages[key] ?? fallback; + + return interpolateMessage(template, params); +}; + +type DebriefStatusOption = "completed" | "partial" | "reoriented"; + +export const DEBRIEF_STATUS_OPTIONS: Array<{ + value: DebriefStatusOption; + labelKey: I18nKey; + descKey: I18nKey; +}> = [ + { + value: "completed", + labelKey: "debrief.option.completed.label", + descKey: "debrief.option.completed.desc", + }, + { + value: "partial", + labelKey: "debrief.option.partial.label", + descKey: "debrief.option.partial.desc", + }, + { + value: "reoriented", + labelKey: "debrief.option.reoriented.label", + descKey: "debrief.option.reoriented.desc", + }, +]; + +export const VOYAGE_STATUS_LABEL_KEYS: Record = { + completed: "status.completed", + partial: "status.partial", + reoriented: "status.reoriented", + aborted: "status.aborted", + in_progress: "status.in_progress", +}; diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 1336e4f..2e832d1 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -3,23 +3,26 @@ import { Route } from '@/shared/types'; export const ROUTES: Route[] = [ { id: 'station', - name: '우주정거장', durationMinutes: 0, - tag: '대기/자유', - description: '시간 제한 없이 머무를 수 있는 안전지대', + nameKey: 'routes.station.name', + tagKey: 'routes.station.tag', + descriptionKey: 'routes.station.description', }, { id: 'orion', - name: '오리온', durationMinutes: 60, - tag: '딥워크', - description: '60분 집중 항해', + nameKey: 'routes.orion.name', + tagKey: 'routes.orion.tag', + descriptionKey: 'routes.orion.description', }, { id: 'gemini', - name: '쌍둥이자리', durationMinutes: 30, - tag: '숏스프린트', - description: '30분 집중 항해', + nameKey: 'routes.gemini.name', + tagKey: 'routes.gemini.tag', + descriptionKey: 'routes.gemini.description', }, ]; + +export const findRouteById = (routeId: string) => + ROUTES.find((route) => route.id === routeId); diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 2c23310..78a9d6c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,9 +1,9 @@ export interface Route { id: string; - name: string; durationMinutes: number; - tag: string; - description: string; + nameKey: string; + tagKey: string; + descriptionKey: string; } export type VoyageStatus = diff --git a/src/widgets/flight-hud/ui/FlightHudWidget.tsx b/src/widgets/flight-hud/ui/FlightHudWidget.tsx index 8dc3125..728aff5 100644 --- a/src/widgets/flight-hud/ui/FlightHudWidget.tsx +++ b/src/widgets/flight-hud/ui/FlightHudWidget.tsx @@ -8,18 +8,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useI18n } from "@/features/i18n/model/useI18n"; +import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n"; +import { findRouteById } from "@/shared/config/routes"; import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store"; import { Voyage, VoyageStatus } from "@/shared/types"; -const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [ - { value: "completed", label: "계획대로", desc: "목표를 달성했습니다" }, - { value: "partial", label: "부분 진행", desc: "절반의 성과를 만들었습니다" }, - { - value: "reoriented", - label: "방향 재설정", - desc: "우선순위를 새로 정했습니다", - }, -]; const FINISH_HOLD_MS = 1000; type FlightHudWidgetProps = { @@ -39,6 +33,7 @@ export function FlightHudWidget({ handlePauseToggle, handleFinish, }: FlightHudWidgetProps) { + const { t } = useI18n(); const router = useRouter(); const [isDebriefOpen, setIsDebriefOpen] = useState(false); const [finishedVoyage, setFinishedVoyage] = useState(null); @@ -130,11 +125,21 @@ export function FlightHudWidget({ if (!voyage) return null; + const route = findRouteById(voyage.routeId); + const routeName = route + ? t(route.nameKey, undefined, voyage.routeName) + : voyage.routeName; + const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({ + value: option.value as VoyageStatus, + label: t(option.labelKey), + desc: t(option.descKey), + })); + return ( <>
- {voyage.routeName} · {isPaused ? "일시정지" : "순항 중"} + {routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
@@ -147,7 +152,7 @@ export function FlightHudWidget({

- 이번 항해 목표 + {t("flight.missionLabel")}

{voyage.missionText} @@ -160,7 +165,7 @@ export function FlightHudWidget({ onClick={handlePauseToggle} className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white" > - {isPaused ? "다시 시작" : "일시정지"} + {isPaused ? t("flight.resume") : t("flight.pause")}

@@ -200,34 +207,36 @@ export function FlightHudWidget({ - 이번 항해를 정리하세요 + {t("flight.debrief.title")} - 짧게 기록하고 항해일지에 저장합니다. + {t("flight.debrief.description")}
-
+
{statusOptions.map((opt) => ( ))}
@@ -235,13 +244,13 @@ export function FlightHudWidget({
setProgress(event.target.value)} - placeholder="예: 기획안 목차 구성 완료" + placeholder={t("debrief.reflection.placeholder")} className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" />
@@ -252,14 +261,14 @@ export function FlightHudWidget({ onClick={() => setIsDebriefOpen(false)} className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white" > - 취소 + {t("boarding.cancel")}
diff --git a/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx index 585fda3..b951b84 100644 --- a/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx +++ b/src/widgets/lobby-routes/ui/LobbyRoutesPanel.tsx @@ -1,16 +1,17 @@ -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from "next/navigation"; +import { useState } from "react"; -import { BoardingMissionForm, startVoyage } from '@/features/boarding'; -import { useLobbyRedirect } from '@/features/lobby-session/model/useLobbyRedirect'; -import { ROUTES } from '@/shared/config/routes'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; +} from "@/components/ui/dialog"; +import { BoardingMissionForm, startVoyage } from "@/features/boarding"; +import { useI18n } from "@/features/i18n/model/useI18n"; +import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect"; +import { ROUTES } from "@/shared/config/routes"; function RouteCard({ route, @@ -21,58 +22,62 @@ function RouteCard({ isCTA?: boolean; onLaunch: (route: (typeof ROUTES)[number]) => void; }) { + const { t } = useI18n(); + return (
-
+

- {route.name} + {t(route.nameKey, undefined, route.id)}

- {route.tag} + {t(route.tagKey)}
- - - {route.durationMinutes === 0 ? '∞' : route.durationMinutes} - - {route.durationMinutes === 0 ? '' : 'min'} + {route.durationMinutes !== 0 && ( + + {route.durationMinutes !== 0 && route.durationMinutes} + + {route.durationMinutes !== 0 && t("common.minuteShort")} + - + )}
{!isCTA && (

- {route.description} + {t(route.descriptionKey)}

)} {isCTA && ( -

- {route.description} +

+ {t(route.descriptionKey)}

)}
); } export function LobbyRoutesPanel() { + const { t } = useI18n(); useLobbyRedirect(); const router = useRouter(); const [selectedRouteId, setSelectedRouteId] = useState(null); @@ -92,31 +97,40 @@ export function LobbyRoutesPanel() { const started = startVoyage({ route: selectedRoute, mission, + routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id), }); if (!started) return; setIsBoardingOpen(false); - router.push('/flight'); + router.push("/flight"); }; return (

- 어느 별자리로 출항할까요? + {t("lobby.title")}

-

몰입하기 좋은 궤도입니다.

+

{t("lobby.subtitle")}

- +
{normalRoutes.map((route) => ( - + ))}
@@ -128,13 +142,15 @@ export function LobbyRoutesPanel() { >

- Boarding Check + {t("lobby.modal.boardingCheck")}

- {selectedRoute.name} 항로 탑승 + {t("lobby.modal.routeBoarding", { + routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id), + })} - 항해를 시작하기 전에 목표를 설정하세요. + {t("lobby.modal.description")}