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 <cursoragent@cursor.com>
This commit is contained in:
55
apps/frontend/src/app/api/locale/route.ts
Normal file
55
apps/frontend/src/app/api/locale/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
apps/frontend/src/app/api/translations/[locale]/route.ts
Normal file
37
apps/frontend/src/app/api/translations/[locale]/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
<html className="h-full" lang="en" suppressHydrationWarning>
|
||||
<body className={cn('h-full antialiased', roboto.className)}>
|
||||
<ThemeProvider>
|
||||
{setupComplete ? (
|
||||
<ChatProvider>
|
||||
<LocalizationProvider>
|
||||
{setupComplete ? (
|
||||
<ChatProvider>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
@@ -48,10 +50,11 @@ export default function RootLayout({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ChatProvider>
|
||||
) : (
|
||||
<SetupWizard configSections={configSections} />
|
||||
)}
|
||||
</ChatProvider>
|
||||
) : (
|
||||
<SetupWizard configSections={configSections} />
|
||||
)}
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 = () => {
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
|
||||
Research begins here.
|
||||
{t('empty.subtitle')}
|
||||
</h2>
|
||||
<EmptyChatMessageInput />
|
||||
</div>
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between mt-4">
|
||||
<Optimization />
|
||||
|
||||
@@ -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' && (
|
||||
<button
|
||||
|
||||
75
apps/frontend/src/lib/localization.ts
Normal file
75
apps/frontend/src/lib/localization.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Клиент для Localization Service.
|
||||
* Получает locale на основе геопозиции (через geo-device-service) и переводы.
|
||||
*/
|
||||
|
||||
const LOCALE_API = '/api/locale';
|
||||
const TRANSLATIONS_API = '/api/translations';
|
||||
|
||||
export interface LocalizationContext {
|
||||
locale: string;
|
||||
language: string;
|
||||
region: string | null;
|
||||
countryCode: string | null;
|
||||
timezone: string | null;
|
||||
source: 'geo' | 'accept-language' | 'client' | 'fallback';
|
||||
}
|
||||
|
||||
const collectClientData = () => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const nav = navigator;
|
||||
return {
|
||||
client: {
|
||||
language: nav.language,
|
||||
languages: nav.languages ? [...nav.languages] : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить locale (серверный вызов — без client data).
|
||||
*/
|
||||
export async function fetchLocale(): Promise<LocalizationContext> {
|
||||
const res = await fetch(LOCALE_API);
|
||||
if (!res.ok) throw new Error('Failed to fetch locale');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить locale с данными клиента (браузер language).
|
||||
*/
|
||||
export async function fetchLocaleWithClient(): Promise<LocalizationContext> {
|
||||
const clientData = collectClientData();
|
||||
const res = await fetch(LOCALE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(clientData),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch locale');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить переводы для locale.
|
||||
*/
|
||||
export async function fetchTranslations(
|
||||
locale: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await fetch(
|
||||
`${TRANSLATIONS_API}/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch translations');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить locale и переводы одним вызовом.
|
||||
*/
|
||||
export async function fetchLocalization(): Promise<{
|
||||
locale: LocalizationContext;
|
||||
translations: Record<string, string>;
|
||||
}> {
|
||||
const locale = await fetchLocaleWithClient();
|
||||
const translations = await fetchTranslations(locale.locale);
|
||||
return { locale, translations };
|
||||
}
|
||||
133
apps/frontend/src/lib/localization/context.tsx
Normal file
133
apps/frontend/src/lib/localization/context.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
fetchLocaleWithClient,
|
||||
fetchTranslations,
|
||||
type LocalizationContext as LocaleContext,
|
||||
} from '@/lib/localization';
|
||||
|
||||
type Translations = Record<string, string>;
|
||||
|
||||
interface LocalizationState {
|
||||
locale: LocaleContext | null;
|
||||
translations: Translations | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface LocalizationContextValue extends LocalizationState {
|
||||
t: (key: string) => string;
|
||||
localeCode: string;
|
||||
}
|
||||
|
||||
const defaultTranslations: Translations = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Search...',
|
||||
'empty.subtitle': 'Research begins here.',
|
||||
'input.askAnything': 'Ask anything...',
|
||||
'input.askFollowUp': 'Ask a follow-up',
|
||||
'chat.send': 'Send',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Retry',
|
||||
};
|
||||
|
||||
const LocalizationContext = createContext<LocalizationContextValue | null>(null);
|
||||
|
||||
export function LocalizationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [state, setState] = useState<LocalizationState>({
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const loc = await fetchLocaleWithClient();
|
||||
if (cancelled) return;
|
||||
const trans = await fetchTranslations(loc.locale);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
locale: loc,
|
||||
translations: trans,
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = loc.locale;
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string): string => {
|
||||
if (state.translations && key in state.translations) {
|
||||
return state.translations[key] ?? key;
|
||||
}
|
||||
return defaultTranslations[key] ?? key;
|
||||
},
|
||||
[state.translations],
|
||||
);
|
||||
|
||||
const localeCode = state.locale?.locale ?? 'en';
|
||||
|
||||
const value = useMemo<LocalizationContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
t,
|
||||
localeCode,
|
||||
}),
|
||||
[state, t, localeCode],
|
||||
);
|
||||
|
||||
return (
|
||||
<LocalizationContext.Provider value={value}>
|
||||
{children}
|
||||
</LocalizationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation(): LocalizationContextValue {
|
||||
const ctx = useContext(LocalizationContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
locale: null,
|
||||
translations: null,
|
||||
loading: false,
|
||||
error: true,
|
||||
t: (key: string) => defaultTranslations[key] ?? key,
|
||||
localeCode: 'en',
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
20
apps/localization-service/package.json
Normal file
20
apps/localization-service/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "localization-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Сервис локализации на основе геопозиции (зависит от geo-device-service)",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "npx tsx watch src/index.ts",
|
||||
"start": "npx tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
26
apps/localization-service/src/index.ts
Normal file
26
apps/localization-service/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import localeRouter from './routes/locale.js';
|
||||
import translationsRouter from './routes/translations.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: true }));
|
||||
const PORT = process.env.PORT || 4003;
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.use('/api', localeRouter);
|
||||
app.use('/api', translationsRouter);
|
||||
|
||||
app.get('/health', (_, res) =>
|
||||
res.json({ status: 'ok', service: 'localization' }),
|
||||
);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Localization Service: http://localhost:${PORT}`);
|
||||
console.log(` Locale (GET): /api/locale`);
|
||||
console.log(` Locale (POST): /api/locale (with client data in body)`);
|
||||
console.log(` Translations: /api/translations/:locale`);
|
||||
console.log(` Supported locales: /api/locales`);
|
||||
});
|
||||
79
apps/localization-service/src/lib/countryToLocale.ts
Normal file
79
apps/localization-service/src/lib/countryToLocale.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Маппинг ISO 3166-1 alpha-2 country code → BCP 47 locale (language).
|
||||
* Приоритет: основные языки по странам.
|
||||
*/
|
||||
export const countryToLocale: Record<string, string> = {
|
||||
RU: 'ru',
|
||||
UA: 'uk',
|
||||
BY: 'be',
|
||||
KZ: 'kk',
|
||||
US: 'en',
|
||||
GB: 'en',
|
||||
AU: 'en',
|
||||
CA: 'en',
|
||||
DE: 'de',
|
||||
AT: 'de',
|
||||
CH: 'de',
|
||||
FR: 'fr',
|
||||
BE: 'fr',
|
||||
ES: 'es',
|
||||
MX: 'es',
|
||||
AR: 'es',
|
||||
IT: 'it',
|
||||
PT: 'pt',
|
||||
BR: 'pt',
|
||||
PL: 'pl',
|
||||
NL: 'nl',
|
||||
SE: 'sv',
|
||||
NO: 'nb',
|
||||
DK: 'da',
|
||||
FI: 'fi',
|
||||
CZ: 'cs',
|
||||
SK: 'sk',
|
||||
HU: 'hu',
|
||||
RO: 'ro',
|
||||
BG: 'bg',
|
||||
HR: 'hr',
|
||||
RS: 'sr',
|
||||
GR: 'el',
|
||||
TR: 'tr',
|
||||
JP: 'ja',
|
||||
CN: 'zh',
|
||||
TW: 'zh-TW',
|
||||
KR: 'ko',
|
||||
IN: 'hi',
|
||||
TH: 'th',
|
||||
VI: 'vi',
|
||||
ID: 'id',
|
||||
MY: 'ms',
|
||||
SA: 'ar',
|
||||
AE: 'ar',
|
||||
EG: 'ar',
|
||||
IL: 'he',
|
||||
IR: 'fa',
|
||||
};
|
||||
|
||||
/** Поддерживаемые локали с fallback. */
|
||||
export const supportedLocales = new Set<string>([
|
||||
'en', 'ru', 'uk', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'zh', 'zh-TW', 'ja', 'ko',
|
||||
]);
|
||||
|
||||
const defaultLocale = 'en';
|
||||
|
||||
export function resolveLocaleFromCountry(countryCode: string | null | undefined): string {
|
||||
if (!countryCode) return defaultLocale;
|
||||
return countryToLocale[countryCode.toUpperCase()] ?? defaultLocale;
|
||||
}
|
||||
|
||||
export function normalizeAcceptLanguage(header: string | undefined): string | null {
|
||||
if (!header || !header.trim()) return null;
|
||||
const first = header.split(',')[0]?.trim();
|
||||
if (!first) return null;
|
||||
const [lang] = first.split(';');
|
||||
const code = lang?.trim().toLowerCase().split('-')[0];
|
||||
return code ?? null;
|
||||
}
|
||||
|
||||
export function getDefaultLocale(): string {
|
||||
return defaultLocale;
|
||||
}
|
||||
31
apps/localization-service/src/lib/geoClient.ts
Normal file
31
apps/localization-service/src/lib/geoClient.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { GeoDeviceContext } from '../types.js';
|
||||
|
||||
const GEO_DEVICE_URL =
|
||||
process.env.GEO_DEVICE_SERVICE_URL ?? 'http://localhost:4002';
|
||||
|
||||
export async function fetchGeoContext(
|
||||
headers: Record<string, string>,
|
||||
body?: unknown,
|
||||
): Promise<GeoDeviceContext | null> {
|
||||
try {
|
||||
const url = `${GEO_DEVICE_URL}/api/context`;
|
||||
const options: RequestInit = {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'user-agent': headers['user-agent'] ?? '',
|
||||
'accept-language': headers['accept-language'] ?? '',
|
||||
'x-forwarded-for': headers['x-forwarded-for'] ?? '',
|
||||
'x-real-ip': headers['x-real-ip'] ?? '',
|
||||
},
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as GeoDeviceContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
69
apps/localization-service/src/lib/resolveLocale.ts
Normal file
69
apps/localization-service/src/lib/resolveLocale.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { GeoDeviceContext, LocalizationContext } from '../types.js';
|
||||
import {
|
||||
resolveLocaleFromCountry,
|
||||
normalizeAcceptLanguage,
|
||||
getDefaultLocale,
|
||||
} from './countryToLocale.js';
|
||||
|
||||
/**
|
||||
* Определяет locale на основе geo-контекста.
|
||||
* Приоритет: геопозиция > client.language > Accept-Language > fallback.
|
||||
* Геопозиция первая — если пользователь в России, показываем русский.
|
||||
*/
|
||||
export function resolveLocale(ctx: GeoDeviceContext | null): LocalizationContext {
|
||||
const fallback: LocalizationContext = {
|
||||
locale: getDefaultLocale(),
|
||||
language: getDefaultLocale(),
|
||||
region: null,
|
||||
countryCode: null,
|
||||
timezone: null,
|
||||
source: 'fallback',
|
||||
};
|
||||
|
||||
if (!ctx) return fallback;
|
||||
|
||||
const geo = ctx.geo;
|
||||
const acceptLang = normalizeAcceptLanguage(ctx.acceptLanguage);
|
||||
const clientLang = ctx.client?.language
|
||||
? ctx.client.language.split('-')[0]?.toLowerCase()
|
||||
: null;
|
||||
|
||||
// 1. Геопозиция (countryCode) — приоритет при определении по IP
|
||||
if (geo?.countryCode) {
|
||||
const locale = resolveLocaleFromCountry(geo.countryCode);
|
||||
return {
|
||||
locale,
|
||||
language: locale,
|
||||
region: geo.region ?? null,
|
||||
countryCode: geo.countryCode,
|
||||
timezone: geo.timezone ?? null,
|
||||
source: 'geo',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Язык из client (браузер navigator.language)
|
||||
if (clientLang) {
|
||||
return {
|
||||
locale: clientLang,
|
||||
language: clientLang,
|
||||
region: geo?.region ?? null,
|
||||
countryCode: geo?.countryCode ?? null,
|
||||
timezone: geo?.timezone ?? null,
|
||||
source: 'client',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Accept-Language заголовок
|
||||
if (acceptLang) {
|
||||
return {
|
||||
locale: acceptLang,
|
||||
language: acceptLang,
|
||||
region: geo?.region ?? null,
|
||||
countryCode: geo?.countryCode ?? null,
|
||||
timezone: geo?.timezone ?? null,
|
||||
source: 'accept-language',
|
||||
};
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
41
apps/localization-service/src/routes/locale.ts
Normal file
41
apps/localization-service/src/routes/locale.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { fetchGeoContext } from '../lib/geoClient.js';
|
||||
import { resolveLocale } from '../lib/resolveLocale.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function getHeaders(req: Request): Record<string, string> {
|
||||
return {
|
||||
'user-agent': req.headers['user-agent'] ?? '',
|
||||
'accept-language': req.headers['accept-language'] ?? '',
|
||||
'x-forwarded-for': (req.headers['x-forwarded-for'] as string) ?? '',
|
||||
'x-real-ip': (req.headers['x-real-ip'] as string) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/locale', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const headers = getHeaders(req);
|
||||
const geoContext = await fetchGeoContext(headers);
|
||||
const localization = resolveLocale(geoContext);
|
||||
res.json(localization);
|
||||
} catch (err) {
|
||||
console.error('Error resolving locale:', err);
|
||||
res.status(500).json({ error: 'Failed to resolve locale' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/locale', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
const headers = getHeaders(req);
|
||||
const geoContext = await fetchGeoContext(headers, body);
|
||||
const localization = resolveLocale(geoContext);
|
||||
res.json(localization);
|
||||
} catch (err) {
|
||||
console.error('Error resolving locale:', err);
|
||||
res.status(500).json({ error: 'Failed to resolve locale' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
34
apps/localization-service/src/routes/translations.ts
Normal file
34
apps/localization-service/src/routes/translations.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import {
|
||||
getTranslations,
|
||||
getSupportedLocales,
|
||||
} from '../translations/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/translations/:locale', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { locale } = req.params;
|
||||
if (!locale) {
|
||||
res.status(400).json({ error: 'Locale is required' });
|
||||
return;
|
||||
}
|
||||
const translations = getTranslations(locale);
|
||||
res.json(translations);
|
||||
} catch (err) {
|
||||
console.error('Error fetching translations:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch translations' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/locales', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const locales = getSupportedLocales();
|
||||
res.json({ locales });
|
||||
} catch (err) {
|
||||
console.error('Error listing locales:', err);
|
||||
res.status(500).json({ error: 'Failed to list locales' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
132
apps/localization-service/src/translations/index.ts
Normal file
132
apps/localization-service/src/translations/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export type TranslationKeys = keyof typeof en;
|
||||
|
||||
const en = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Search...',
|
||||
'empty.subtitle': 'Research begins here.',
|
||||
'input.askAnything': 'Ask anything...',
|
||||
'input.askFollowUp': 'Ask a follow-up',
|
||||
'chat.send': 'Send',
|
||||
'chat.newChat': 'New Chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'discover.title': 'Discover',
|
||||
'discover.region': 'Region',
|
||||
'weather.title': 'Weather',
|
||||
'weather.feelsLike': 'Feels like',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Retry',
|
||||
} as const;
|
||||
|
||||
const ru = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Поиск...',
|
||||
'empty.subtitle': 'Исследование начинается здесь.',
|
||||
'input.askAnything': 'Спросите что угодно...',
|
||||
'input.askFollowUp': 'Задайте уточняющий вопрос',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.newChat': 'Новый чат',
|
||||
'chat.suggestions': 'Подсказки',
|
||||
'discover.title': 'Обзор',
|
||||
'discover.region': 'Регион',
|
||||
'weather.title': 'Погода',
|
||||
'weather.feelsLike': 'Ощущается как',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.error': 'Ошибка',
|
||||
'common.retry': 'Повторить',
|
||||
} as const;
|
||||
|
||||
const de = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Suchen...',
|
||||
'empty.subtitle': 'Die Recherche beginnt hier.',
|
||||
'input.askAnything': 'Fragen Sie alles...',
|
||||
'input.askFollowUp': 'Folgefrage stellen',
|
||||
'chat.send': 'Senden',
|
||||
'chat.newChat': 'Neuer Chat',
|
||||
'chat.suggestions': 'Vorschläge',
|
||||
'discover.title': 'Entdecken',
|
||||
'discover.region': 'Region',
|
||||
'weather.title': 'Wetter',
|
||||
'weather.feelsLike': 'Gefühlt wie',
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Fehler',
|
||||
'common.retry': 'Wiederholen',
|
||||
} as const;
|
||||
|
||||
const fr = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Rechercher...',
|
||||
'empty.subtitle': 'La recherche commence ici.',
|
||||
'input.askAnything': 'Posez une question...',
|
||||
'input.askFollowUp': 'Poser une question de suivi',
|
||||
'chat.send': 'Envoyer',
|
||||
'chat.newChat': 'Nouveau chat',
|
||||
'chat.suggestions': 'Suggestions',
|
||||
'discover.title': 'Découvrir',
|
||||
'discover.region': 'Région',
|
||||
'weather.title': 'Météo',
|
||||
'weather.feelsLike': 'Ressenti',
|
||||
'common.loading': 'Chargement...',
|
||||
'common.error': 'Erreur',
|
||||
'common.retry': 'Réessayer',
|
||||
} as const;
|
||||
|
||||
const es = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Buscar...',
|
||||
'empty.subtitle': 'La investigación comienza aquí.',
|
||||
'input.askAnything': 'Pregunte lo que sea...',
|
||||
'input.askFollowUp': 'Hacer una pregunta de seguimiento',
|
||||
'chat.send': 'Enviar',
|
||||
'chat.newChat': 'Nuevo chat',
|
||||
'chat.suggestions': 'Sugerencias',
|
||||
'discover.title': 'Descubrir',
|
||||
'discover.region': 'Región',
|
||||
'weather.title': 'Clima',
|
||||
'weather.feelsLike': 'Sensación térmica',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.error': 'Error',
|
||||
'common.retry': 'Reintentar',
|
||||
} as const;
|
||||
|
||||
const uk = {
|
||||
'app.title': 'GooSeek',
|
||||
'app.searchPlaceholder': 'Пошук...',
|
||||
'empty.subtitle': 'Дослідження починається тут.',
|
||||
'input.askAnything': 'Запитайте що завгодно...',
|
||||
'input.askFollowUp': 'Задайте уточнююче питання',
|
||||
'chat.send': 'Надіслати',
|
||||
'chat.newChat': 'Новий чат',
|
||||
'chat.suggestions': 'Підказки',
|
||||
'discover.title': 'Огляд',
|
||||
'discover.region': 'Регіон',
|
||||
'weather.title': 'Погода',
|
||||
'weather.feelsLike': 'Відчувається як',
|
||||
'common.loading': 'Завантаження...',
|
||||
'common.error': 'Помилка',
|
||||
'common.retry': 'Повторити',
|
||||
} as const;
|
||||
|
||||
export const translations: Record<string, Record<TranslationKeys, string>> = {
|
||||
en,
|
||||
ru,
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
uk,
|
||||
};
|
||||
|
||||
const defaultLocale = 'en';
|
||||
|
||||
export function getTranslations(locale: string): Record<TranslationKeys, string> {
|
||||
const base = locale.split('-')[0]?.toLowerCase() ?? defaultLocale;
|
||||
return (translations[base] ?? translations[defaultLocale]) as Record<
|
||||
TranslationKeys,
|
||||
string
|
||||
>;
|
||||
}
|
||||
|
||||
export function getSupportedLocales(): string[] {
|
||||
return Object.keys(translations);
|
||||
}
|
||||
34
apps/localization-service/src/types.ts
Normal file
34
apps/localization-service/src/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface GeoLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
region: string;
|
||||
city: string;
|
||||
timezone: string;
|
||||
source: 'ip' | 'client' | 'unknown';
|
||||
}
|
||||
|
||||
export interface GeoDeviceContext {
|
||||
geo: GeoLocation | null;
|
||||
device: Record<string, unknown>;
|
||||
browser: Record<string, unknown>;
|
||||
os: Record<string, unknown>;
|
||||
client: {
|
||||
language?: string;
|
||||
languages?: string[];
|
||||
};
|
||||
ip?: string;
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface LocalizationContext {
|
||||
locale: string;
|
||||
language: string;
|
||||
region: string | null;
|
||||
countryCode: string | null;
|
||||
timezone: string | null;
|
||||
source: 'geo' | 'accept-language' | 'client' | 'fallback';
|
||||
}
|
||||
13
apps/localization-service/tsconfig.json
Normal file
13
apps/localization-service/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user