refactor(i18n): 사용자 문구 참조를 중앙화

This commit is contained in:
2026-03-10 13:32:37 +09:00
parent 92a509ebb6
commit 1717f335f0
44 changed files with 433 additions and 515 deletions

View File

@@ -1,4 +1,5 @@
import type { AuthResponse } from '@/features/auth/types';
import { copy } from '@/shared/i18n';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
@@ -57,14 +58,14 @@ const parseErrorMessage = async (response: Response) => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return `요청 실패: ${response.status}`;
return copy.common.requestFailed(response.status);
}
try {
const payload = (await response.json()) as ApiErrorPayload;
return payload.message ?? payload.error ?? `요청 실패: ${response.status}`;
return payload.message ?? payload.error ?? copy.common.requestFailed(response.status);
} catch {
return `요청 실패: ${response.status}`;
return copy.common.requestFailed(response.status);
}
};

View File

@@ -1,6 +1,7 @@
"use client";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { copy } from '@/shared/i18n';
import { useSocialLogin } from "../hooks/useSocialLogin";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
@@ -46,7 +47,7 @@ const SocialLoginButtons = () => {
fill="#EA4335"
/>
</svg>
{isLoading ? "연결 중..." : "Google로 계속하기"}
{isLoading ? copy.auth.social.connecting : copy.auth.social.continueWithGoogle}
</button>
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}

View File

@@ -2,6 +2,7 @@ import { useGoogleLogin } from '@react-oauth/google';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import appleAuthHelpers from 'react-apple-signin-auth';
import { copy } from '@/shared/i18n';
import { useAuthStore } from '@/store/useAuthStore';
import { authApi } from '../api/authApi';
@@ -58,7 +59,7 @@ export const useSocialLogin = () => {
router.push('/app');
} catch (err) {
console.error(`[${provider}] 로그인 실패:`, err);
setError('로그인에 실패했습니다. 다시 시도해 주세요.');
setError(copy.auth.errors.loginFailed);
} finally {
setIsLoading(false);
}
@@ -73,7 +74,7 @@ export const useSocialLogin = () => {
handleSocialLogin('google', tokenResponse.access_token);
},
onError: () => {
setError('구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.');
setError(copy.auth.errors.googleFailed);
},
});
@@ -99,12 +100,12 @@ export const useSocialLogin = () => {
},
onError: (err: AppleSignInError) => {
console.error('Apple SignIn error:', err);
setError('애플 로그인 중 오류가 발생했습니다.');
setError(copy.auth.errors.appleFailed);
},
});
} catch (err) {
console.error(err);
setError('애플 로그인 초기화 실패');
setError(copy.auth.errors.appleInitFailed);
}
};
@@ -116,7 +117,7 @@ export const useSocialLogin = () => {
if (response?.accessToken) {
handleSocialLogin('facebook', response.accessToken);
} else {
setError('페이스북 로그인에 실패했습니다.');
setError(copy.auth.errors.facebookFailed);
}
};

View File

@@ -1,6 +1,7 @@
'use client';
import type { KeyboardEvent } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { useHoldToConfirm } from '../model/useHoldToConfirm';
@@ -39,7 +40,7 @@ export const ExitHoldButton = ({
return (
<button
type="button"
aria-label="길게 눌러 나가기"
aria-label={copy.space.exitHold.holdToExitAriaLabel}
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
@@ -90,7 +91,7 @@ export const ExitHoldButton = ({
return (
<button
type="button"
aria-label="길게 눌러 나가기"
aria-label={copy.space.exitHold.holdToExitAriaLabel}
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
@@ -117,7 +118,7 @@ export const ExitHoldButton = ({
) : null}
<span className="relative z-10 inline-flex items-center gap-1">
<span aria-hidden></span>
<span></span>
<span>{copy.space.exitHold.exit}</span>
</span>
</button>
);

View File

@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import {
focusSessionApi,
type CompleteFocusSessionRequest,
@@ -96,7 +97,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
return applySession(session);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '세션 엔진과 동기화하지 못했어요.';
nextError instanceof Error ? nextError.message : copy.focusSession.syncFailed;
setError(message);
return null;
} finally {
@@ -188,7 +189,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
startSession: async (payload) => {
const session = await runMutation(
() => focusSessionApi.startSession(payload),
'세션을 시작하지 못했어요.',
copy.focusSession.startFailed,
);
return applySession(session);
@@ -200,7 +201,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const session = await runMutation(
() => focusSessionApi.pauseSession(),
'세션을 일시정지하지 못했어요.',
copy.focusSession.pauseFailed,
);
return applySession(session);
@@ -212,7 +213,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const session = await runMutation(
() => focusSessionApi.resumeSession(),
'세션을 다시 시작하지 못했어요.',
copy.focusSession.resumeFailed,
);
return applySession(session);
@@ -224,7 +225,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const session = await runMutation(
() => focusSessionApi.restartCurrentPhase(),
'현재 페이즈를 다시 시작하지 못했어요.',
copy.focusSession.restartPhaseFailed,
);
return applySession(session);
@@ -236,7 +237,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const session = await runMutation(
() => focusSessionApi.completeSession(payload),
'세션을 완료 처리하지 못했어요.',
copy.focusSession.completeFailed,
);
if (session) {
@@ -252,7 +253,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const result = await runMutation(
() => focusSessionApi.abandonSession(),
'세션을 종료하지 못했어요.',
copy.focusSession.abandonFailed,
);
if (result === null) {

View File

@@ -1,4 +1,5 @@
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
interface InboxListProps {
@@ -17,7 +18,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
className,
)}
>
. .
{copy.space.inbox.empty}
</p>
);
}
@@ -51,14 +52,14 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
)}
>
{thought.isCompleted ? '완료됨' : '완료'}
{thought.isCompleted ? copy.space.inbox.completed : copy.space.inbox.complete}
</button>
<button
type="button"
onClick={() => onDeleteThought(thought)}
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
>
{copy.space.inbox.delete}
</button>
</div>
</li>

View File

@@ -1,3 +1,5 @@
import { copy } from '@/shared/i18n';
interface ManagePlanSheetContentProps {
onClose: () => void;
onManage: () => void;
@@ -12,8 +14,8 @@ export const ManagePlanSheetContent = ({
return (
<div className="space-y-4">
<header className="space-y-1">
<h3 className="text-lg font-semibold tracking-tight text-white">PRO </h3>
<p className="text-xs text-white/62">/ .</p>
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.manageTitle}</h3>
<p className="text-xs text-white/62">{copy.space.paywall.manageDescription}</p>
</header>
<div className="space-y-2">
@@ -22,14 +24,14 @@ export const ManagePlanSheetContent = ({
onClick={onManage}
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
>
{copy.space.paywall.openSubscription}
</button>
<button
type="button"
onClick={onRestore}
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
>
{copy.space.paywall.restorePurchase}
</button>
</div>
@@ -39,7 +41,7 @@ export const ManagePlanSheetContent = ({
onClick={onClose}
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
>
{copy.common.close}
</button>
</div>
</div>

View File

@@ -1,22 +1,20 @@
'use client';
import { copy } from '@/shared/i18n';
interface PaywallSheetContentProps {
onStartPro: () => void;
onClose: () => void;
}
const VALUE_POINTS = [
'프리미엄 Scene Packs',
'확장 Sound Packs',
'프로필 저장 / 불러오기',
];
const VALUE_POINTS = copy.space.paywall.points;
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
return (
<div className="space-y-4">
<header className="space-y-1">
<h3 className="text-lg font-semibold tracking-tight text-white">PRO에서 .</h3>
<p className="text-xs text-white/62"> .</p>
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.title}</h3>
<p className="text-xs text-white/62">{copy.space.paywall.description}</p>
</header>
<ul className="space-y-2">
@@ -33,14 +31,14 @@ export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContent
onClick={onClose}
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
>
{copy.space.paywall.later}
</button>
<button
type="button"
onClick={onStartPro}
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
>
PRO
{copy.space.paywall.startPro}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type { PlanTier } from '@/entities/plan';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
interface PlanPillProps {
@@ -20,7 +21,7 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
)}
>
{isPro ? 'PRO' : 'Normal'}
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
</button>
);
};

View File

@@ -2,6 +2,7 @@ import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions';
import { copy } from '@/shared/i18n';
import { apiClient } from '@/shared/lib/apiClient';
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
@@ -17,7 +18,7 @@ export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
reduceMotion: false,
notificationIntensity: '기본',
notificationIntensity: copy.preferences.defaultNotificationIntensity,
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
};

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import {
DEFAULT_USER_FOCUS_PREFERENCES,
preferencesApi,
@@ -57,7 +58,7 @@ export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
setError(null);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '설정을 불러오지 못했어요.';
nextError instanceof Error ? nextError.message : copy.preferences.loadFailed;
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
setError(message);
} finally {
@@ -80,13 +81,13 @@ export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
try {
const persisted = await preferencesApi.updateFocusPreferences(patch);
setPreferences(persisted);
pushSavedLabel('저장됨');
pushSavedLabel(copy.preferences.saved);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '설정을 저장하지 못했어요.';
nextError instanceof Error ? nextError.message : copy.preferences.saveFailed;
setPreferences(previous);
setError(message);
pushSavedLabel('저장 실패');
pushSavedLabel(copy.preferences.saveFailedLabel);
} finally {
setSaving(false);
}

View File

@@ -1,4 +1,6 @@
export const RECOVERY_30S_BUTTON_LABEL = '숨 고르기 30초';
export const RECOVERY_30S_MODE_LABEL = 'BREATHE';
export const RECOVERY_30S_TOAST_MESSAGE = '잠깐 숨 고르고, 다시 천천히 시작해요.';
export const RECOVERY_30S_COMPLETE_MESSAGE = '준비됐어요. 집중으로 돌아가요.';
import { copy } from '@/shared/i18n';
export const RECOVERY_30S_BUTTON_LABEL = copy.restart30s.button;
export const RECOVERY_30S_MODE_LABEL = copy.restart30s.mode;
export const RECOVERY_30S_TOAST_MESSAGE = copy.restart30s.toast;
export const RECOVERY_30S_COMPLETE_MESSAGE = copy.restart30s.complete;

View File

@@ -1,5 +1,6 @@
import type { SceneTheme } from '@/entities/scene';
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
interface SceneSelectCarouselProps {
@@ -33,7 +34,7 @@ export const SceneSelectCarousel = ({
: 'border-white/16 hover:border-white/24',
)}
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
aria-label={`${scene.name} 선택`}
aria-label={`${scene.name} ${copy.common.select}`}
>
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
{selected ? (

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { copy } from '@/shared/i18n';
import type { GoalChip } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
@@ -21,6 +22,7 @@ export const SessionGoalField = ({
onGoalChange,
onGoalChipSelect,
}: SessionGoalFieldProps) => {
const { sessionGoal } = copy.space;
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
@@ -41,7 +43,7 @@ export const SessionGoalField = ({
<div className="space-y-2">
<div className="space-y-1">
<label htmlFor="space-goal-input" className="text-[12px] font-medium text-white/88">
25, <span className="text-sky-100">()</span>
{sessionGoal.label} <span className="text-sky-100">{sessionGoal.required}</span>
</label>
<input
ref={inputRef}
@@ -49,10 +51,10 @@ export const SessionGoalField = ({
autoFocus={autoFocus}
value={goalInput}
onChange={(event) => onGoalChange(event.target.value)}
placeholder="예: 계약서 1페이지 정리"
placeholder={sessionGoal.placeholder}
className="h-10 w-full rounded-xl border border-white/14 bg-slate-950/42 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/46 focus:outline-none"
/>
<p className="text-[11px] text-white/54"> , .</p>
<p className="text-[11px] text-white/54">{sessionGoal.hint}</p>
</div>
<div className="flex flex-wrap gap-1.5">

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { SoundAssetManifestItem } from '@/entities/media';
import { copy } from '@/shared/i18n';
interface UseSoundPlaybackOptions {
selectedPresetId: string;
@@ -66,7 +67,7 @@ export const useSoundPlayback = ({
const handleError = () => {
setReady(false);
setPlaying(false);
setError('사운드 파일을 불러오지 못했어요.');
setError(copy.soundPlayback.loadFailed);
};
audio.addEventListener('loadstart', handleLoadStart);
@@ -146,7 +147,7 @@ export const useSoundPlayback = ({
} catch {
if (!cancelled) {
setPlaying(false);
setError('브라우저가 사운드 재생을 보류했어요.');
setError(copy.soundPlayback.browserDeferred);
}
}
};

View File

@@ -1,4 +1,5 @@
import { SOUND_PRESETS } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { Toggle } from '@/shared/ui';
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
@@ -16,13 +17,7 @@ interface SoundPresetControlsProps {
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
}
const TRACK_LABELS: Record<SoundTrackKey, string> = {
white: 'White',
rain: 'Rain',
cafe: 'Cafe',
wave: 'Wave',
fan: 'Fan',
};
const TRACK_LABELS: Record<SoundTrackKey, string> = copy.space.soundPresetControls.trackLabels;
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
@@ -39,10 +34,11 @@ export const SoundPresetControls = ({
trackLevels,
onTrackLevelChange,
}: SoundPresetControlsProps) => {
const { soundPresetControls } = copy.space;
return (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.11em] text-white/58">Preset</p>
<p className="text-xs uppercase tracking-[0.11em] text-white/58">{soundPresetControls.preset}</p>
<div className="grid grid-cols-2 gap-2">
{SOUND_PRESETS.map((preset) => (
<button
@@ -66,16 +62,16 @@ export const SoundPresetControls = ({
onClick={onToggleMixer}
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
>
<span>{isMixerOpen ? 'Mixer 접기' : 'Mixer 펼치기'}</span>
<span>{isMixerOpen ? soundPresetControls.mixerClose : soundPresetControls.mixerOpen}</span>
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-white/86 ring-1 ring-white/20">
{soundPresetControls.mock}
</span>
</button>
{isMixerOpen ? (
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/78"> </span>
<span className="text-xs text-white/78">{soundPresetControls.masterVolume}</span>
<span className="text-[11px] text-white/58">{masterVolume}%</span>
</div>
<input
@@ -90,11 +86,11 @@ export const SoundPresetControls = ({
/>
<div className="flex items-center justify-between border-t border-white/12 pt-2">
<p className="text-xs text-white/78"></p>
<p className="text-xs text-white/78">{soundPresetControls.mute}</p>
<Toggle
checked={isMuted}
onChange={onMuteChange}
ariaLabel="마스터 뮤트 토글"
ariaLabel={soundPresetControls.muteToggleAriaLabel}
/>
</div>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
type StatsSource = 'api' | 'mock';
@@ -55,7 +56,7 @@ export const useFocusStats = (): UseFocusStatsResult => {
setError(null);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '통계를 불러오지 못했어요.';
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
setSummary(buildMockSummary());
setSource('mock');
setError(message);