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:
home
2026-02-20 21:45:44 +03:00
parent 4269c8d99b
commit f4d945a2b5
21 changed files with 878 additions and 107 deletions

View 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);
}
}

View 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 },
);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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

View 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 };
}

View 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;
}

View 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"
}
}

View 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`);
});

View 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;
}

View 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;
}
}

View 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;
}

View 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;

View 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;

View 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);
}

View 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';
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}