Compare commits
3 Commits
v0.1.35
...
515f5c1d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
515f5c1d1a | ||
|
|
6cbf7feaf5 | ||
|
|
fda2d76514 |
@@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🧹 #329 admin/page.tsx 분리 (v0.1.35)
|
||||||
|
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
|
||||||
|
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
|
||||||
|
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)
|
||||||
|
- SSE 통일은 후속 #351 분리
|
||||||
|
- 설계서: docs/design/329-admin-split/README.md
|
||||||
|
- Refs: #329 (close)
|
||||||
|
|
||||||
### ⚡ #331 VectorService batchUpdate (v0.1.34)
|
### ⚡ #331 VectorService batchUpdate (v0.1.34)
|
||||||
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
|
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
|
||||||
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
|
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
|
||||||
|
|||||||
141
docs/design/352-i18n-skeleton/README.md
Normal file
141
docs/design/352-i18n-skeleton/README.md
Normal file
@@ -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 (`<NextIntlClientProvider>`) 루트 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/<lang>.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) — 후속.
|
||||||
2092
frontend/package-lock.json
generated
2092
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@types/supercluster": "^7.1.3",
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"supercluster": "^8.0.1"
|
"supercluster": "^8.0.1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
import LoginMenu from "@/components/LoginMenu";
|
import LoginMenu from "@/components/LoginMenu";
|
||||||
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
@@ -719,6 +720,8 @@ export default function Home() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* #352 — Language switcher */}
|
||||||
|
<LanguageSwitcher />
|
||||||
{/* Desktop user area */}
|
{/* Desktop user area */}
|
||||||
{authLoading ? null : user ? (
|
{authLoading ? null : user ? (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
import { AuthProvider } from "@/lib/auth-context";
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { LocaleProvider } from "@/i18n/LocaleProvider";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
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 }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<LocaleProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</GoogleOAuthProvider>
|
</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