From f4d945a2b57aea5bfc57e66425a2f59b58ff99a9 Mon Sep 17 00:00:00 2001 From: home Date: Fri, 20 Feb 2026 21:45:44 +0300 Subject: [PATCH] feat: localization service and frontend integration - Add localization-service microservice (locale resolution, translations) - Add frontend API routes /api/locale and /api/translations/[locale] - Add LocalizationProvider and localization context - Integrate localization into layout, EmptyChat, MessageInput components - Update MICROSERVICES.md architecture docs - Add localization-service to workspaces Co-authored-by: Cursor --- apps/frontend/src/app/api/locale/route.ts | 55 ++++++++ .../app/api/translations/[locale]/route.ts | 37 +++++ apps/frontend/src/app/layout.tsx | 15 +- apps/frontend/src/components/EmptyChat.tsx | 7 +- .../src/components/EmptyChatMessageInput.tsx | 4 +- apps/frontend/src/components/MessageInput.tsx | 4 +- apps/frontend/src/lib/localization.ts | 75 ++++++++++ .../frontend/src/lib/localization/context.tsx | 133 ++++++++++++++++++ apps/localization-service/package.json | 20 +++ apps/localization-service/src/index.ts | 26 ++++ .../src/lib/countryToLocale.ts | 79 +++++++++++ .../localization-service/src/lib/geoClient.ts | 31 ++++ .../src/lib/resolveLocale.ts | 69 +++++++++ .../localization-service/src/routes/locale.ts | 41 ++++++ .../src/routes/translations.ts | 34 +++++ .../src/translations/index.ts | 132 +++++++++++++++++ apps/localization-service/src/types.ts | 34 +++++ apps/localization-service/tsconfig.json | 13 ++ docs/architecture/MICROSERVICES.md | 43 +++++- package-lock.json | 130 ++++++----------- package.json | 3 +- 21 files changed, 878 insertions(+), 107 deletions(-) create mode 100644 apps/frontend/src/app/api/locale/route.ts create mode 100644 apps/frontend/src/app/api/translations/[locale]/route.ts create mode 100644 apps/frontend/src/lib/localization.ts create mode 100644 apps/frontend/src/lib/localization/context.tsx create mode 100644 apps/localization-service/package.json create mode 100644 apps/localization-service/src/index.ts create mode 100644 apps/localization-service/src/lib/countryToLocale.ts create mode 100644 apps/localization-service/src/lib/geoClient.ts create mode 100644 apps/localization-service/src/lib/resolveLocale.ts create mode 100644 apps/localization-service/src/routes/locale.ts create mode 100644 apps/localization-service/src/routes/translations.ts create mode 100644 apps/localization-service/src/translations/index.ts create mode 100644 apps/localization-service/src/types.ts create mode 100644 apps/localization-service/tsconfig.json diff --git a/apps/frontend/src/app/api/locale/route.ts b/apps/frontend/src/app/api/locale/route.ts new file mode 100644 index 0000000..dfde8b7 --- /dev/null +++ b/apps/frontend/src/app/api/locale/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const LOCALIZATION_SERVICE_URL = + process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003'; + +const fallbackLocale = { + locale: 'en', + language: 'en', + region: null, + countryCode: null, + timezone: null, + source: 'fallback' as const, +}; + +export async function GET(req: NextRequest) { + try { + const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, { + headers: { + 'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '', + 'x-real-ip': req.headers.get('x-real-ip') ?? '', + 'user-agent': req.headers.get('user-agent') ?? '', + 'accept-language': req.headers.get('accept-language') ?? '', + }, + }); + + if (!res.ok) return NextResponse.json(fallbackLocale); + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json(fallbackLocale); + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '', + 'x-real-ip': req.headers.get('x-real-ip') ?? '', + 'user-agent': req.headers.get('user-agent') ?? '', + 'accept-language': req.headers.get('accept-language') ?? '', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) return NextResponse.json(fallbackLocale); + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json(fallbackLocale); + } +} diff --git a/apps/frontend/src/app/api/translations/[locale]/route.ts b/apps/frontend/src/app/api/translations/[locale]/route.ts new file mode 100644 index 0000000..9b27908 --- /dev/null +++ b/apps/frontend/src/app/api/translations/[locale]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const LOCALIZATION_SERVICE_URL = + process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ locale: string }> }, +) { + try { + const { locale } = await params; + if (!locale) { + return NextResponse.json( + { error: 'Locale is required' }, + { status: 400 }, + ); + } + + const res = await fetch( + `${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(locale)}`, + ); + + if (!res.ok) { + return NextResponse.json( + { error: 'Failed to fetch translations' }, + { status: 502 }, + ); + } + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { error: 'Failed to fetch translations' }, + { status: 500 }, + ); + } +} diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 89363bc..4fbee76 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; import Sidebar from '@/components/Sidebar'; import { Toaster } from 'sonner'; import ThemeProvider from '@/components/theme/Provider'; +import { LocalizationProvider } from '@/lib/localization/context'; import configManager from '@/lib/config'; import SetupWizard from '@/components/Setup/SetupWizard'; import { ChatProvider } from '@/lib/hooks/useChat'; @@ -36,8 +37,9 @@ export default function RootLayout({ - {setupComplete ? ( - + + {setupComplete ? ( + {children} - - ) : ( - - )} + + ) : ( + + )} + diff --git a/apps/frontend/src/components/EmptyChat.tsx b/apps/frontend/src/components/EmptyChat.tsx index f4c24a9..b4ab7f4 100644 --- a/apps/frontend/src/components/EmptyChat.tsx +++ b/apps/frontend/src/components/EmptyChat.tsx @@ -1,10 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Settings } from 'lucide-react'; import EmptyChatMessageInput from './EmptyChatMessageInput'; -import { File } from './ChatWindow'; -import Link from 'next/link'; import WeatherWidget from './WeatherWidget'; import NewsArticleWidget from './NewsArticleWidget'; import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile'; @@ -12,8 +9,10 @@ import { getShowNewsWidget, getShowWeatherWidget, } from '@/lib/config/clientRegistry'; +import { useTranslation } from '@/lib/localization/context'; const EmptyChat = () => { + const { t } = useTranslation(); const [showWeather, setShowWeather] = useState(() => typeof window !== 'undefined' ? getShowWeatherWidget() : true, ); @@ -64,7 +63,7 @@ const EmptyChat = () => {

- Research begins here. + {t('empty.subtitle')}

diff --git a/apps/frontend/src/components/EmptyChatMessageInput.tsx b/apps/frontend/src/components/EmptyChatMessageInput.tsx index 206f524..5b35dc8 100644 --- a/apps/frontend/src/components/EmptyChatMessageInput.tsx +++ b/apps/frontend/src/components/EmptyChatMessageInput.tsx @@ -5,10 +5,12 @@ import Sources from './MessageInputActions/Sources'; import Optimization from './MessageInputActions/Optimization'; import Attach from './MessageInputActions/Attach'; import { useChat } from '@/lib/hooks/useChat'; +import { useTranslation } from '@/lib/localization/context'; import ModelSelector from './MessageInputActions/ChatModelSelector'; const EmptyChatMessageInput = () => { const { sendMessage } = useChat(); + const { t } = useTranslation(); /* const [copilotEnabled, setCopilotEnabled] = useState(false); */ const [message, setMessage] = useState(''); @@ -62,7 +64,7 @@ const EmptyChatMessageInput = () => { onChange={(e) => setMessage(e.target.value)} minRows={2} className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48" - placeholder="Ask anything..." + placeholder={t('input.askAnything')} />
diff --git a/apps/frontend/src/components/MessageInput.tsx b/apps/frontend/src/components/MessageInput.tsx index 281dde2..686c37a 100644 --- a/apps/frontend/src/components/MessageInput.tsx +++ b/apps/frontend/src/components/MessageInput.tsx @@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import AttachSmall from './MessageInputActions/AttachSmall'; import { useChat } from '@/lib/hooks/useChat'; +import { useTranslation } from '@/lib/localization/context'; const MessageInput = () => { const { loading, sendMessage } = useChat(); + const { t } = useTranslation(); const [copilotEnabled, setCopilotEnabled] = useState(false); const [message, setMessage] = useState(''); @@ -74,7 +76,7 @@ const MessageInput = () => { setTextareaRows(Math.ceil(height / props.rowHeight)); }} className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink" - placeholder="Ask a follow-up" + placeholder={t('input.askFollowUp')} /> {mode === 'single' && (