feat(i18n): #352 다국어 뼈대 ko/en/ja/es

- next-intl 5.x 도입
- src/i18n/config.ts: LOCALES 상수, detectBrowserLocale, LOCALE_LABELS(국기/네이티브명)
- src/i18n/LocaleProvider.tsx: NextIntlClientProvider wrap + localStorage tasteby_locale 저장
- src/messages/{ko,en,ja,es}.json: 초기 30개 키 (header/actions/filter/restaurant/review 5 카테고리)
- src/components/LanguageSwitcher.tsx: 헤더용 드롭다운 (국기 + native, ARIA listbox, 44px 터치)
- providers.tsx: LocaleProvider로 AuthProvider 감싸기
- page.tsx 헤더에 LanguageSwitcher 배치

설계서: docs/design/352-i18n-skeleton/README.md (Approved)

언어 선택 근거:
- ko: 기본
- en: 글로벌 1순위
- ja: 일본 사용자 + 한국 음식 관광
- es: 5억 화자, 라틴아메리카 + 스페인 확장

미번역 키는 ko fallback. URL 라우팅(/en/)/SEO meta/사용자 콘텐츠 번역은 후속.

Refs: #352
This commit is contained in:
joungmin
2026-06-15 15:58:21 +09:00
parent fda2d76514
commit 6cbf7feaf5
12 changed files with 1164 additions and 4 deletions

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { api } from "@/lib/api";
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
@@ -719,6 +720,8 @@ export default function Home() {
</>
)}
<div className="flex-1" />
{/* #352 — Language switcher */}
<LanguageSwitcher />
{/* Desktop user area */}
{authLoading ? null : user ? (
<div className="flex items-center gap-2 shrink-0">

View File

@@ -2,6 +2,7 @@
import { GoogleOAuthProvider } from "@react-oauth/google";
import { AuthProvider } from "@/lib/auth-context";
import { LocaleProvider } from "@/i18n/LocaleProvider";
import type { ReactNode } from "react";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
@@ -9,7 +10,9 @@ const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
export function Providers({ children }: { children: ReactNode }) {
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<AuthProvider>{children}</AuthProvider>
<LocaleProvider>
<AuthProvider>{children}</AuthProvider>
</LocaleProvider>
</GoogleOAuthProvider>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useLocale } from "@/i18n/LocaleProvider";
import { LOCALES, LOCALE_LABELS } from "@/i18n/config";
// #352 — 헤더용 언어 전환 드롭다운. 44px 터치 영역 + ARIA listbox 패턴.
export default function LanguageSwitcher() {
const { locale, setLocale } = useLocale();
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="언어 선택 / Select language"
className="min-h-[44px] min-w-[44px] px-2 py-1.5 flex items-center gap-1 text-sm rounded-lg hover:bg-brand-50 touch-manipulation"
>
<span aria-hidden="true">{LOCALE_LABELS[locale].flag}</span>
<span className="text-xs text-gray-500 uppercase">{locale}</span>
</button>
{open && (
<>
<button
aria-hidden="true"
tabIndex={-1}
className="fixed inset-0 z-40 cursor-default"
onClick={() => setOpen(false)}
/>
<ul
role="listbox"
aria-label="언어 목록"
className="absolute right-0 mt-1 bg-surface rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50 min-w-[160px]"
>
{LOCALES.map((l) => (
<li key={l}>
<button
role="option"
aria-selected={l === locale}
onClick={() => { setLocale(l); setOpen(false); }}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-brand-50 ${
l === locale ? "font-semibold text-brand-700" : "text-gray-700"
}`}
>
<span aria-hidden="true">{LOCALE_LABELS[l].flag}</span>
<span>{LOCALE_LABELS[l].native}</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { NextIntlClientProvider } from "next-intl";
import { useEffect, useState } from "react";
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, Locale, detectBrowserLocale, isLocale } from "./config";
// #352 — 메시지 파일을 정적 import (Tree-shaking 가능, 클라이언트 번들에 4언어 모두 포함되지만
// 30개 키 수준이라 부담 미미. 키가 늘어나면 동적 import로 분할 검토)
import ko from "@/messages/ko.json";
import en from "@/messages/en.json";
import ja from "@/messages/ja.json";
import es from "@/messages/es.json";
const MESSAGES: Record<Locale, typeof ko> = { ko, en, ja, es };
interface LocaleContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
}
import { createContext, useContext } from "react";
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
return ctx;
}
export function LocaleProvider({ children }: { children: React.ReactNode }) {
// SSR 단계는 기본 로케일로 시작 (hydration mismatch 방지)
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
const initial: Locale = isLocale(saved) ? saved : detectBrowserLocale();
if (initial !== locale) setLocaleState(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setLocale = (l: Locale) => {
setLocaleState(l);
if (typeof window !== "undefined") {
localStorage.setItem(LOCALE_STORAGE_KEY, l);
}
};
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider locale={locale} messages={MESSAGES[locale]}>
{children}
</NextIntlClientProvider>
</LocaleContext.Provider>
);
}

View File

@@ -0,0 +1,28 @@
// #352 i18n 뼈대 — 로케일 목록/기본값
export const LOCALES = ["ko", "en", "ja", "es"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "ko";
export const LOCALE_STORAGE_KEY = "tasteby_locale";
export const LOCALE_LABELS: Record<Locale, { flag: string; label: string; native: string }> = {
ko: { flag: "🇰🇷", label: "Korean", native: "한국어" },
en: { flag: "🇺🇸", label: "English", native: "English" },
ja: { flag: "🇯🇵", label: "Japanese", native: "日本語" },
es: { flag: "🇪🇸", label: "Spanish", native: "Español" },
};
export function isLocale(value: string | null | undefined): value is Locale {
return value != null && (LOCALES as readonly string[]).includes(value);
}
/**
* 브라우저 언어 감지 → 지원 로케일이면 그것, 아니면 기본값.
* SSR-safe (typeof window 체크 호출자).
*/
export function detectBrowserLocale(): Locale {
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
const code = navigator.language.split("-")[0].toLowerCase();
return isLocale(code) ? code : DEFAULT_LOCALE;
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "Search",
"login": "Sign In",
"logout": "Sign Out",
"menu": "Menu",
"myReviews": "My Reviews",
"favorites": "Favorites"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"confirm": "OK",
"close": "Close",
"loading": "Loading...",
"submit": "Submit"
},
"filter": {
"title": "Filter",
"cuisine": "Cuisine",
"price": "Price Range",
"region": "Region",
"channel": "Channel",
"reset": "Reset"
},
"restaurant": {
"rating": "Rating",
"address": "Address",
"phone": "Phone",
"website": "Website",
"closed": "Permanently Closed",
"tempClosed": "Temporarily Closed"
},
"review": {
"title": "Reviews",
"write": "Write a Review",
"noReviews": "No reviews yet"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "Buscar",
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"menu": "Menú",
"myReviews": "Mis reseñas",
"favorites": "Favoritos"
},
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"confirm": "Aceptar",
"close": "Cerrar",
"loading": "Cargando...",
"submit": "Enviar"
},
"filter": {
"title": "Filtro",
"cuisine": "Tipo de cocina",
"price": "Rango de precios",
"region": "Región",
"channel": "Canal",
"reset": "Restablecer"
},
"restaurant": {
"rating": "Calificación",
"address": "Dirección",
"phone": "Teléfono",
"website": "Sitio web",
"closed": "Cerrado permanentemente",
"tempClosed": "Cerrado temporalmente"
},
"review": {
"title": "Reseñas",
"write": "Escribir una reseña",
"noReviews": "Aún no hay reseñas"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "検索",
"login": "ログイン",
"logout": "ログアウト",
"menu": "メニュー",
"myReviews": "マイレビュー",
"favorites": "お気に入り"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"confirm": "確認",
"close": "閉じる",
"loading": "読み込み中...",
"submit": "送信"
},
"filter": {
"title": "フィルター",
"cuisine": "料理ジャンル",
"price": "価格帯",
"region": "地域",
"channel": "チャンネル",
"reset": "リセット"
},
"restaurant": {
"rating": "評価",
"address": "住所",
"phone": "電話",
"website": "ウェブサイト",
"closed": "閉店",
"tempClosed": "一時休業"
},
"review": {
"title": "レビュー",
"write": "レビューを書く",
"noReviews": "まだレビューがありません"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "검색",
"login": "로그인",
"logout": "로그아웃",
"menu": "메뉴",
"myReviews": "내 리뷰",
"favorites": "즐겨찾기"
},
"actions": {
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"edit": "수정",
"confirm": "확인",
"close": "닫기",
"loading": "로딩 중...",
"submit": "제출"
},
"filter": {
"title": "필터",
"cuisine": "음식 종류",
"price": "가격대",
"region": "지역",
"channel": "채널",
"reset": "초기화"
},
"restaurant": {
"rating": "평점",
"address": "주소",
"phone": "전화",
"website": "웹사이트",
"closed": "폐업",
"tempClosed": "임시휴업"
},
"review": {
"title": "리뷰",
"write": "리뷰 작성",
"noReviews": "아직 리뷰가 없습니다"
}
}