Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import ru from '../locales/ru.json' with { type: 'json' };
|
||||
import en from '../locales/en.json' with { type: 'json' };
|
||||
import zh from '../locales/zh.json' with { type: 'json' };
|
||||
import hy from '../locales/hy.json' with { type: 'json' };
|
||||
import es from '../locales/es.json' with { type: 'json' };
|
||||
|
||||
export const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const SOURCE_LOCALE: Locale = 'ru';
|
||||
|
||||
/** Подписи языков в переключателе (как в макете) */
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN', zh: '中文', hy: 'ՀԱՅ', es: 'ES' };
|
||||
/** Полное название языка на нём самом */
|
||||
export const LOCALE_NAMES: Record<Locale, string> = { ru: 'Русский', en: 'English', zh: '中文', hy: 'Հայերեն', es: 'Español' };
|
||||
/** BCP 47 для Intl */
|
||||
export const INTL_LOCALE: Record<Locale, string> = { ru: 'ru-RU', en: 'en-GB', zh: 'zh-CN', hy: 'hy-AM', es: 'es-ES' };
|
||||
|
||||
type Tree = { [k: string]: string | Tree };
|
||||
export type Messages = Record<string, string>;
|
||||
|
||||
/** {"a":{"b":"x"}} → {"a.b":"x"} */
|
||||
export function flatten(tree: Tree, prefix = '', out: Messages = {}): Messages {
|
||||
for (const [k, v] of Object.entries(tree)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === 'string') out[key] = v;
|
||||
else flatten(v, key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const resources: Record<Locale, Messages> = {
|
||||
ru: flatten(ru as Tree),
|
||||
en: flatten(en as Tree),
|
||||
zh: flatten(zh as Tree),
|
||||
hy: flatten(hy as Tree),
|
||||
es: flatten(es as Tree),
|
||||
};
|
||||
|
||||
export type TParams = Record<string, string | number | null | undefined>;
|
||||
export type TFunction = (key: string, params?: TParams) => string;
|
||||
|
||||
const pluralRules = new Map<Locale, Intl.PluralRules>();
|
||||
function plural(locale: Locale, n: number): string {
|
||||
let r = pluralRules.get(locale);
|
||||
if (!r) pluralRules.set(locale, (r = new Intl.PluralRules(INTL_LOCALE[locale])));
|
||||
return r.select(n);
|
||||
}
|
||||
|
||||
function interpolate(s: string, params?: TParams) {
|
||||
if (!params) return s;
|
||||
return s.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k: string) => String(params[k] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевод с фолбэком на русский. Множественное число: ключи `<key>_one`, `_few`, `_many`, `_other`
|
||||
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
|
||||
*/
|
||||
const merged = new Map<Locale, Messages>();
|
||||
function withFallback(locale: Locale): Messages {
|
||||
let m = merged.get(locale);
|
||||
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
|
||||
return m;
|
||||
}
|
||||
|
||||
export function createT(locale: Locale): TFunction {
|
||||
return tFromMessages(locale, withFallback(locale));
|
||||
}
|
||||
|
||||
/**
|
||||
* Подмножество переводов (с фолбэком на русский) для передачи в клиентский компонент:
|
||||
* сервер отдаёт только нужные префиксы, а не все словари.
|
||||
*/
|
||||
export function pickMessages(locale: Locale, prefixes: string[]): Messages {
|
||||
const out: Messages = {};
|
||||
for (const [k, v] of Object.entries(withFallback(locale))) if (prefixes.some((p) => k.startsWith(p))) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** t() поверх готового набора сообщений — те же правила множественного числа и подстановок, что у createT */
|
||||
export function tFromMessages(locale: Locale, messages: Messages): TFunction {
|
||||
return (key, params) => {
|
||||
let k = key;
|
||||
if (typeof params?.count === 'number') {
|
||||
const form = `${key}_${plural(locale, params.count)}`;
|
||||
if (messages[form]) k = form;
|
||||
else if (messages[`${key}_other`]) k = `${key}_other`;
|
||||
}
|
||||
const s = messages[k];
|
||||
return s === undefined ? key : interpolate(s, params);
|
||||
};
|
||||
}
|
||||
|
||||
export function isLocale(v: unknown): v is Locale {
|
||||
return typeof v === 'string' && (LOCALES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** Шрифты: Rubik/Onest не содержат армянский и китайский — для них фолбэк */
|
||||
export const FONT_FALLBACK: Record<Locale, string> = {
|
||||
ru: '',
|
||||
en: '',
|
||||
es: '',
|
||||
hy: "'Noto Sans Armenian', ",
|
||||
zh: "'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', ",
|
||||
};
|
||||
|
||||
export function formatDateTime(locale: Locale, date: Date, opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }) {
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { timeZone: 'Asia/Yerevan', ...opts }).format(date);
|
||||
}
|
||||
|
||||
export function formatAmd(locale: Locale, amount: number) {
|
||||
return `${new Intl.NumberFormat(INTL_LOCALE[locale], { maximumFractionDigits: 0 }).format(amount)} ֏`;
|
||||
}
|
||||
Reference in new issue
Block a user