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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/src/components/LanguageSwitcher.tsx
Normal file
57
frontend/src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/i18n/LocaleProvider.tsx
Normal file
56
frontend/src/i18n/LocaleProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/i18n/config.ts
Normal file
28
frontend/src/i18n/config.ts
Normal 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;
|
||||
}
|
||||
41
frontend/src/messages/en.json
Normal file
41
frontend/src/messages/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/es.json
Normal file
41
frontend/src/messages/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/ja.json
Normal file
41
frontend/src/messages/ja.json
Normal 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": "まだレビューがありません"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/ko.json
Normal file
41
frontend/src/messages/ko.json
Normal 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": "아직 리뷰가 없습니다"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user