From 6cbf7feaf5f6d228707192592b112ca1308c4225 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 15:58:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20#352=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EB=BC=88=EB=8C=80=20ko/en/ja/es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/design/352-i18n-skeleton/README.md | 141 ++++ frontend/package-lock.json | 712 ++++++++++++++++++- frontend/package.json | 2 + frontend/src/app/page.tsx | 3 + frontend/src/app/providers.tsx | 5 +- frontend/src/components/LanguageSwitcher.tsx | 57 ++ frontend/src/i18n/LocaleProvider.tsx | 56 ++ frontend/src/i18n/config.ts | 28 + frontend/src/messages/en.json | 41 ++ frontend/src/messages/es.json | 41 ++ frontend/src/messages/ja.json | 41 ++ frontend/src/messages/ko.json | 41 ++ 12 files changed, 1164 insertions(+), 4 deletions(-) create mode 100644 docs/design/352-i18n-skeleton/README.md create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/LocaleProvider.tsx create mode 100644 frontend/src/i18n/config.ts create mode 100644 frontend/src/messages/en.json create mode 100644 frontend/src/messages/es.json create mode 100644 frontend/src/messages/ja.json create mode 100644 frontend/src/messages/ko.json diff --git a/docs/design/352-i18n-skeleton/README.md b/docs/design/352-i18n-skeleton/README.md new file mode 100644 index 0000000..80d16f0 --- /dev/null +++ b/docs/design/352-i18n-skeleton/README.md @@ -0,0 +1,141 @@ +# 설계서: i18n 뼈대 — ko/en/ja/es (#352) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #352 +> · 구현 파일: `frontend/package.json`, `frontend/next.config.ts`, `frontend/src/i18n/*` (신규), `frontend/src/messages/{ko,en,ja,es}.json` (신규), `frontend/src/components/LanguageSwitcher.tsx` (신규) +> · 테스트: 수동 (언어 전환 + ko fallback) + +## 1. 목적 + +장기적으로 영어/일본어/스페인어 시장으로 확장 가능하도록 i18n 뼈대 구축. 본 이슈는 *뼈대*만 — 약 30개 키 초기 번역. + +## 2. 범위 + +- **포함** + - `next-intl` 라이브러리 도입 (Next.js 16 App Router 권장). + - 4개 로케일: `ko, en, ja, es`. 기본 `ko`, fallback `ko`. + - 메시지 디렉토리 `src/messages/{ko,en,ja,es}.json`. + - Provider (``) 루트 layout 적용. + - `LanguageSwitcher` 컴포넌트 (헤더 우측). + - 로케일 저장: localStorage `tasteby_locale`. + - 초기 번역 키 ~30개: 헤더(로그인/검색/메뉴) + 주요 액션(저장/취소/삭제/확인) + 페이지 제목. +- **제외** + - URL 라우팅 i18n (`/ko/...`). + - SEO meta tags i18n. + - 식당명/리뷰 등 사용자 콘텐츠 번역. + - 어드민 페이지(운영자만 사용, 한국어 유지). + - 식당 카드/상세 시트 전체 키 추출 (점진). + +## 3. 인수조건 + +- [ ] `npm i next-intl` + 4개 메시지 파일 생성. +- [ ] `tasteby_locale`이 localStorage에 있으면 사용, 없으면 브라우저 언어 감지(`navigator.language`) → 매칭 안되면 `ko`. +- [ ] 헤더 우측 LanguageSwitcher 드롭다운(국기 + 코드). +- [ ] 초기 번역 키 약 30개 — 4개 언어 모두 채움. +- [ ] 미번역 키는 `ko` fallback (에러 없이). +- [ ] 빌드/배포 회귀 없음. + +## 4. 컨텍스트 & 제약 + +- Next.js 16 + App Router + `"use client"` 컴포넌트 다수. +- 기존 `auth-context`처럼 i18n도 React Context 패턴. +- `next-intl`은 server/client 모두 지원, 본 프로젝트는 client-side switching이라 `NextIntlClientProvider` 중심. +- URL 라우팅 변경 없이 단순 메시지만 교체(낮은 비용). +- 폰트: Pretendard Variable이 한국어/영어 잘 표시, 일본어는 시스템 폰트 fallback OK, 스페인어는 라틴 문자라 OK. + +## 5. 아키텍처 개요 + +``` +frontend/ +├── src/ +│ ├── i18n/ +│ │ ├── config.ts ← 로케일 목록/기본값 상수 +│ │ ├── LocaleProvider.tsx ← NextIntlClientProvider wrap + localStorage 저장 +│ │ └── useTranslations.ts ← (next-intl 재export) +│ ├── messages/ +│ │ ├── ko.json ← 기본 +│ │ ├── en.json +│ │ ├── ja.json +│ │ └── es.json +│ ├── components/ +│ │ └── LanguageSwitcher.tsx ← 헤더용 +│ └── app/ +│ └── layout.tsx ← LocaleProvider로 감싸기 +``` + +## 6. 데이터 모델 + +### 메시지 키 (초기 ~30) +```json +{ + "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": "아직 리뷰가 없습니다" + } +} +``` + +총 5개 카테고리 × 평균 6개 = 30개 키. + +## 7. 함수 명세 + +| 함수/컴포넌트 | 책임 | 비고 | +|---|---|---| +| `i18n/config.ts` | LOCALES, DEFAULT_LOCALE 상수 | 단순 | +| `LocaleProvider.tsx` | NextIntlClientProvider wrap + 메시지 동적 로딩 + localStorage 동기화 | client | +| `LanguageSwitcher.tsx` | 헤더 드롭다운 (국기 + 코드) | client, 44px 터치 | +| `messages/.json` | 키 → 텍스트 | flat or nested | + +## 8. 흐름 + +1. 사용자 첫 방문 → `tasteby_locale` 없음 → `navigator.language.split('-')[0]`이 LOCALES에 있으면 사용, 아니면 `ko`. +2. LocaleProvider가 해당 로케일 메시지 파일 import → NextIntlClientProvider에 전달. +3. 컴포넌트는 `useTranslations('header')` 등으로 호출. +4. LanguageSwitcher에서 변경 → localStorage 저장 → 페이지 새로고침 또는 state 업데이트. + +## 9. 엣지케이스 + +- **메시지 파일 누락 키**: next-intl 기본 동작은 키 자체 표시 + 콘솔 경고. fallback 처리는 messages 명시. +- **localStorage 비활성/SSR**: typeof window 체크. +- **로케일 코드 대소문자**: 항상 소문자 정규화. +- **placeholder 변수**: next-intl ICU 메시지 형식 지원 (`{name}` 등). 초기 키에는 미적용. + +## 10. 테스트 + +- 수동: + - 한국어 첫 방문 → "검색" 표시. + - LanguageSwitcher에서 English → "Search" 표시. + - 새로고침 후 영어 유지. + - 메시지 누락 키 → 콘솔 경고 + 키 표시. + +## 11. 리스크 & 대안 + +- **선택**: `next-intl` (Next.js 16 App Router 권장, ICU 메시지, 활발 유지보수). +- **대안 A**: `react-i18next` — 더 일반적이지만 App Router 통합 next-intl이 더 매끄러움. +- **대안 B**: 자체 구현 + Context — 의존성 ↓ but 기능/표준화 ↓. +- **트레이드오프**: 30개 키는 단순하지만 ICU 메시지(복수형, 성별 등) 필요 시 next-intl 가치 큼. + +## 12. 미해결 질문 + +- 식당명/리뷰 콘텐츠 번역 — 사용자 작성이라 자동 번역(LLM)? 별도 정책 결정. +- URL 라우팅 i18n (`/en/`) — 후속. +- SEO meta tags i18n — 후속. +- 어드민 페이지는 운영자 한국어 유지 — 확정. +- 통화/날짜 포맷(Intl API) — 후속. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 014e215..908c3f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,13 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@react-oauth/google": "^0.13.4", "@tabler/icons-react": "^3.40.0", "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "supercluster": "^8.0.1" @@ -459,6 +461,36 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz", + "integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz", + "integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.10" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz", + "integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz", + "integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1232,6 +1264,326 @@ "node": ">=12.4.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@react-oauth/google": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", @@ -1249,6 +1601,210 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1258,6 +1814,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tabler/icons": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz", @@ -2868,7 +3433,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4007,6 +4571,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/icu-minify": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz", + "integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4059,6 +4638,16 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz", + "integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6", + "@formatjs/icu-messageformat-parser": "3.5.11" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4221,7 +4810,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4267,7 +4855,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5071,6 +5658,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -5124,6 +5720,83 @@ } } }, + "node_modules/next-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz", + "integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.13.0", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.13.0", + "po-parser": "^2.1.1", + "use-intl": "^4.13.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz", + "integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5152,6 +5825,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5428,6 +6107,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6531,6 +7216,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz", + "integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.13.0", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 85f845d..8671313 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "@react-oauth/google": "^0.13.4", "@tabler/icons-react": "^3.40.0", "@types/supercluster": "^7.1.3", "@vis.gl/react-google-maps": "^1.7.1", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "supercluster": "^8.0.1" diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9ce6335..bf22d6f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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() { )}
+ {/* #352 — Language switcher */} + {/* Desktop user area */} {authLoading ? null : user ? (
diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index a4f15b4..df432d4 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..a5aa312 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -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 ( +
+ + {open && ( + <> + + + ))} + + + )} +
+ ); +} diff --git a/frontend/src/i18n/LocaleProvider.tsx b/frontend/src/i18n/LocaleProvider.tsx new file mode 100644 index 0000000..101d288 --- /dev/null +++ b/frontend/src/i18n/LocaleProvider.tsx @@ -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 = { ko, en, ja, es }; + +interface LocaleContextValue { + locale: Locale; + setLocale: (l: Locale) => void; +} + +import { createContext, useContext } from "react"; +const LocaleContext = createContext(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(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 ( + + + {children} + + + ); +} diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 0000000..9951310 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -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 = { + 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; +} diff --git a/frontend/src/messages/en.json b/frontend/src/messages/en.json new file mode 100644 index 0000000..f83c3a8 --- /dev/null +++ b/frontend/src/messages/en.json @@ -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" + } +} diff --git a/frontend/src/messages/es.json b/frontend/src/messages/es.json new file mode 100644 index 0000000..80fb4ad --- /dev/null +++ b/frontend/src/messages/es.json @@ -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" + } +} diff --git a/frontend/src/messages/ja.json b/frontend/src/messages/ja.json new file mode 100644 index 0000000..ed01cad --- /dev/null +++ b/frontend/src/messages/ja.json @@ -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": "まだレビューがありません" + } +} diff --git a/frontend/src/messages/ko.json b/frontend/src/messages/ko.json new file mode 100644 index 0000000..6cd0118 --- /dev/null +++ b/frontend/src/messages/ko.json @@ -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": "아직 리뷰가 없습니다" + } +}