feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
173
services/web-svc/src/lib/localization.ts
Normal file
173
services/web-svc/src/lib/localization.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Клиент для Localization Service.
|
||||
* Получает locale на основе геопозиции (через geo-device-service) и переводы.
|
||||
*/
|
||||
|
||||
import { fetchContextWithGeolocation } from './geoDevice';
|
||||
import { localeFromCountryCode } from './localization/countryToLocale';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** IP-провайдеры возвращают country_code — fallback когда geo-device недоступен */
|
||||
async function fetchCountryFromIp(): Promise<string | null> {
|
||||
const providers: Array<{
|
||||
url: string;
|
||||
getCountry: (d: Record<string, unknown>) => string | null;
|
||||
}> = [
|
||||
{
|
||||
url: 'https://get.geojs.io/v1/ip/geo.json',
|
||||
getCountry: (d) => {
|
||||
const cc = d.country_code ?? d.country;
|
||||
return typeof cc === 'string' ? cc.toUpperCase() : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
url: 'https://ipwhois.app/json/',
|
||||
getCountry: (d) => {
|
||||
const cc = d.country_code ?? d.country;
|
||||
return typeof cc === 'string' ? cc.toUpperCase() : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const res = await fetch(p.url);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
const country = p.getCountry(d);
|
||||
if (country) return country;
|
||||
} catch {
|
||||
/* следующий провайдер */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCALE = 'ru';
|
||||
|
||||
/**
|
||||
* Получить locale с приоритетом geo-context.
|
||||
* По умолчанию — русский. Только если геопозиция из другой страны — используем её язык.
|
||||
*/
|
||||
export async function fetchLocaleWithGeoFirst(): Promise<LocalizationContext> {
|
||||
try {
|
||||
const ctx = await fetchContextWithGeolocation();
|
||||
const cc = ctx.geo?.countryCode;
|
||||
if (cc) {
|
||||
const locale = localeFromCountryCode(cc);
|
||||
return {
|
||||
locale,
|
||||
language: locale,
|
||||
region: ctx.geo?.region ?? null,
|
||||
countryCode: cc,
|
||||
timezone: ctx.geo?.timezone ?? null,
|
||||
source: 'geo',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* geo-context недоступен */
|
||||
}
|
||||
try {
|
||||
const countryCode = await fetchCountryFromIp();
|
||||
if (countryCode) {
|
||||
const locale = localeFromCountryCode(countryCode);
|
||||
return {
|
||||
locale,
|
||||
language: locale,
|
||||
region: null,
|
||||
countryCode,
|
||||
timezone: null,
|
||||
source: 'geo',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* IP fallback недоступен */
|
||||
}
|
||||
return {
|
||||
locale: DEFAULT_LOCALE,
|
||||
language: DEFAULT_LOCALE,
|
||||
region: null,
|
||||
countryCode: null,
|
||||
timezone: null,
|
||||
source: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
import {
|
||||
getEmbeddedTranslations,
|
||||
translationLocaleFor,
|
||||
} from './localization/embeddedTranslations';
|
||||
|
||||
/**
|
||||
* Получить переводы для locale.
|
||||
* При недоступности API использует встроенные переводы.
|
||||
*/
|
||||
export async function fetchTranslations(
|
||||
locale: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const fetchLocale = translationLocaleFor(locale);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${TRANSLATIONS_API}/${encodeURIComponent(fetchLocale)}`,
|
||||
);
|
||||
if (res.ok) return res.json();
|
||||
} catch {
|
||||
/* API недоступен */
|
||||
}
|
||||
return getEmbeddedTranslations(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user