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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+112
View File
@@ -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)} ֏`;
}