feat: add runtime locale overrides

This commit is contained in:
3252a8
2026-05-25 17:47:45 +03:00
parent 23784d00fe
commit 59fa301344
51 changed files with 4026 additions and 214 deletions
@@ -0,0 +1,175 @@
import { writable } from "svelte/store";
function dirtyId(lang, key) {
return `${lang}:${key}`;
}
function normalizeLanguageCode(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
}
function isValidLanguageCode(value) {
return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) && value.length >= 2 && value.length <= 16;
}
function languageLabel(code) {
const labels = {
ru: "Русский",
en: "English",
de: "Deutsch",
es: "Español",
fr: "Français",
"pt-br": "Português (BR)",
uk: "Українська",
};
return labels[code] || code.toUpperCase();
}
export function createTranslationsStore({ api, onToast, at }) {
const state = writable({
translationGroups: [],
translationLanguages: [],
translationsLoading: false,
translationsDirty: {},
translationsSaving: false,
translationsPath: "",
translationsOverrideCount: 0,
});
async function loadTranslations() {
state.update((s) => ({ ...s, translationsLoading: true, translationsDirty: {} }));
try {
const data = await api("/admin/translations");
if (data?.ok) {
state.update((s) => ({
...s,
translationGroups: data.groups || [],
translationLanguages: data.languages || [],
translationsPath: data.path || "",
translationsOverrideCount: data.override_count || 0,
}));
}
} finally {
state.update((s) => ({ ...s, translationsLoading: false }));
}
}
function markDirty(lang, key, value, deleted = false) {
state.update((s) => ({
...s,
translationsDirty: {
...s.translationsDirty,
[dirtyId(lang, key)]: { lang, key, value, deleted },
},
}));
}
function clearDirty(lang, key) {
state.update((s) => {
const next = { ...s.translationsDirty };
delete next[dirtyId(lang, key)];
return { ...s, translationsDirty: next };
});
}
function resetField(lang, key, overridden) {
if (overridden) {
markDirty(lang, key, "", true);
} else {
clearDirty(lang, key);
}
}
function addTranslationLanguage(rawCode) {
const code = normalizeLanguageCode(rawCode);
if (!isValidLanguageCode(code)) {
onToast(at("translations_language_invalid", {}, "Invalid language code"));
return false;
}
let exists = false;
state.update((s) => {
exists = (s.translationLanguages || []).some((lang) => lang.code === code);
if (exists) return s;
return {
...s,
translationLanguages: [
...(s.translationLanguages || []),
{ code, label: languageLabel(code), base: false },
].sort((a, b) => a.code.localeCompare(b.code)),
};
});
if (exists) {
onToast(at("translations_language_exists", { code }, `${code} already exists`));
return false;
}
return true;
}
async function saveTranslations(onTranslationsSaved) {
let dirty = {};
state.update((s) => {
dirty = s.translationsDirty;
return s;
});
if (!Object.keys(dirty).length) return true;
state.update((s) => ({ ...s, translationsSaving: true }));
try {
const updates = {};
const deletes = [];
for (const change of Object.values(dirty)) {
if (change.deleted || String(change.value ?? "") === "") {
deletes.push({ lang: change.lang, key: change.key });
continue;
}
if (!updates[change.lang]) updates[change.lang] = {};
updates[change.lang][change.key] = change.value;
}
const res = await api("/admin/translations", {
method: "PATCH",
body: JSON.stringify({ updates, deletes }),
});
if (res?.ok) {
onToast(
res.file_written === false
? at(
"translations_file_write_warning",
{},
"Translations saved in DB, but JSON file was not updated"
)
: at("translations_saved", {}, "Translations saved")
);
state.update((s) => ({ ...s, translationsDirty: {} }));
if (onTranslationsSaved) await onTranslationsSaved({ updates, deletes });
await loadTranslations();
return true;
}
if (res?.errors) {
const summary = Object.entries(res.errors)
.map(([key, value]) => `${key}: ${value}`)
.join("; ");
onToast(at("translations_validation_errors", { errors: summary }, `Errors: ${summary}`));
} else {
onToast(at("translations_save_error", { error: res?.error || "" }, res?.error || "Error"));
}
return false;
} finally {
state.update((s) => ({ ...s, translationsSaving: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadTranslations,
markDirty,
clearDirty,
resetField,
addTranslationLanguage,
saveTranslations,
};
}
+1
View File
@@ -28,6 +28,7 @@ export {
Home,
Info,
Key,
Languages,
LayoutDashboard,
LifeBuoy,
Lock,
+114 -2
View File
@@ -1,23 +1,134 @@
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
export const LANGUAGE_LABELS = {
ru: "Русский",
en: "English",
de: "Deutsch",
es: "Español",
fr: "Français",
"pt-br": "Português (BR)",
ru: "Русский",
tr: "Türkçe",
uk: "Українська",
};
export const LANGUAGE_FLAGS = {
ru: "🇷🇺",
en: "🇬🇧",
de: "🇩🇪",
es: "🇪🇸",
fr: "🇫🇷",
"pt-br": "🇧🇷",
ru: "🇷🇺",
tr: "🇹🇷",
uk: "🇺🇦",
};
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
export const LOCALE_KEY_ALIASES = {
admin_apply: "wa_apply",
admin_ads_col_status: "admin_status",
admin_ad_label_source: "admin_ads_col_source",
admin_back: "wa_back",
admin_btn_refresh: "admin_refresh",
admin_btn_save: "admin_save",
admin_btn_saving: "admin_saving",
admin_close: "wa_close",
admin_copied: "wa_copied",
admin_copy: "wa_copy",
admin_csv_amount: "admin_amount",
admin_csv_description: "admin_description",
admin_csv_payment_id: "admin_id",
admin_csv_status: "admin_status",
admin_link_copied: "wa_link_copied",
admin_next: "wa_next",
admin_payment_detail_copied: "wa_copied",
admin_payment_detail_provider: "admin_provider",
admin_payment_detail_provider_section: "admin_provider",
admin_payment_detail_user_section: "admin_user",
admin_payments_col_user_id: "admin_id",
admin_promo_col_code: "admin_promo_csv_code",
admin_promo_col_status: "admin_status",
admin_promo_csv_is_active: "admin_badge_active",
admin_promo_csv_status: "admin_status",
admin_promo_label_code: "admin_promo_csv_code",
admin_promo_unlimited_validity: "admin_promo_unlimited",
admin_stats_revenue_custom_range_apply: "wa_apply",
admin_stats_revenue_tooltip_amount: "admin_amount",
admin_stats_sync_status: "admin_status",
admin_status_active: "admin_badge_active",
admin_support_category: "wa_support_category",
admin_support_category_account: "wa_support_category_account",
admin_support_category_billing: "wa_support_category_billing",
admin_support_category_other: "wa_support_category_other",
admin_support_category_technical: "wa_support_category_technical",
admin_support_close_ticket: "wa_close",
admin_support_empty: "wa_support_empty",
admin_support_filter_active: "wa_support_filter_active",
admin_support_filter_all: "wa_support_filter_all",
admin_support_internal_note: "wa_support_internal_note",
admin_support_no_messages: "wa_support_no_messages",
admin_support_priority: "wa_support_priority",
admin_support_priority_high: "wa_support_priority_high",
admin_support_priority_low: "wa_support_priority_low",
admin_support_priority_normal: "wa_support_priority_normal",
admin_support_priority_urgent: "wa_support_priority_urgent",
admin_support_role_system: "wa_support_role_system",
admin_support_role_user: "admin_user",
admin_support_search: "admin_search",
admin_support_status: "admin_status",
admin_support_status_awaiting_admin: "wa_support_status_awaiting_admin",
admin_support_status_awaiting_user: "wa_support_status_awaiting_user",
admin_support_status_closed: "wa_support_status_closed",
admin_support_status_open: "wa_support_status_open",
admin_support_status_resolved: "wa_support_status_resolved",
admin_support_ticket_number: "wa_support_ticket_number",
admin_support_user_context: "admin_user",
admin_tariffs_legacy_traffic_packages: "admin_tariff_traffic_packages",
admin_tariffs_stat_enabled: "admin_enabled",
admin_user_btn_cancel: "wa_cancel",
admin_user_history_until: "wa_until_date",
admin_user_label_provider: "admin_provider",
admin_user_short: "admin_user",
admin_user_stats_total_label: "admin_total",
back_to_autopay_method_choice_button: "back_to_main_menu_button",
back_to_payment_methods_button: "back_to_main_menu_button",
cancel_broadcast_button: "cancel_button",
csv_no: "no_button",
csv_yes: "yes_button",
user_premium_override_status_unlimited: "user_regular_override_status_unlimited",
user_regular_override_save: "admin_save",
wa_devices_disconnect_title: "wa_devices_disconnect",
wa_install_link_copied: "wa_link_copied",
wa_link_email_modal_title: "wa_settings_link_email_action",
};
export function resolveLocaleKey(key) {
let value = String(key || "").trim();
const seen = new Set();
while (LOCALE_KEY_ALIASES[value] && !seen.has(value)) {
seen.add(value);
value = LOCALE_KEY_ALIASES[value];
}
return value;
}
export function normalizeLanguageCode(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/_/g, "-");
}
export function uniqueLanguageCodes(...sources) {
const seen = new Set();
const result = [];
for (const source of sources) {
for (const item of source || []) {
const code = normalizeLanguageCode(typeof item === "string" ? item : item?.code);
if (!code || seen.has(code)) continue;
seen.add(code);
result.push(code);
}
}
return result;
}
export const APP_SECTION_PATHS = {
home: "/home",
install: "/install",
@@ -39,6 +150,7 @@ export const ADMIN_SECTIONS = new Set([
"support",
"tariffs",
"appearance",
"translations",
"settings",
]);
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
+10 -12
View File
@@ -1,4 +1,4 @@
import { LANGUAGE_LABELS } from "./constants.js";
import { LANGUAGE_LABELS, normalizeLanguageCode, resolveLocaleKey } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
@@ -21,14 +21,13 @@ export function createI18n({
mergeMessages(initialMessages);
function normalizeLangCode(lang) {
const key = String(lang || "")
.trim()
.toLowerCase();
const key = normalizeLanguageCode(lang);
if (!key) return defaultLang;
const base = key.split("-")[0];
if (LANGUAGE_LABELS[base]) return base;
if (messages[base]) return base;
if (messages[key]) return key;
if (messages[base]) return base;
if (LANGUAGE_LABELS[key]) return key;
if (LANGUAGE_LABELS[base]) return base;
return defaultLang;
}
@@ -38,10 +37,11 @@ export function createI18n({
function t(key, params = {}, fallback = "") {
const lang = currentLang();
const lookupKey = resolveLocaleKey(key);
const variants = [
messages?.[lang]?.[key],
messages?.en?.[key],
messages?.ru?.[key],
messages?.[lang]?.[lookupKey],
messages?.en?.[lookupKey],
messages?.ru?.[lookupKey],
fallback,
key,
];
@@ -50,9 +50,7 @@ export function createI18n({
}
function languageName(code) {
const key = String(code || "")
.trim()
.toLowerCase();
const key = normalizeLanguageCode(code);
if (!key) return t("wa_language_default");
return LANGUAGE_LABELS[key] || key.toUpperCase();
}
+72
View File
@@ -472,6 +472,78 @@ export async function mockApi(path, options = {}, context = {}) {
}
return { ok: true, applied: 1, reverted: 0 };
}
if (path === "/admin/translations" && String(options.method || "GET").toUpperCase() === "PATCH") {
return { ok: true, applied: 1, reverted: 0, file_written: true };
}
if (path === "/admin/translations") {
return {
ok: true,
path: "data/locales-overrides.json",
override_count: 1,
languages: [
{ code: "en", label: "English", base: true },
{ code: "ru", label: "Русский", base: true },
],
groups: [
{
id: "webapp",
title: "Mini App",
description: "User-facing Mini App strings.",
audience: "user",
items: [
{
key: "wa_nav_home",
audience: "user",
values: {
ru: {
base: "Главная",
fallback: "Главная",
effective: "Главная",
override: "",
overridden: false,
},
en: {
base: "Home",
fallback: "Главная",
effective: "Dashboard",
override: "Dashboard",
overridden: true,
},
},
},
],
},
{
id: "admin",
title: "Admin panel",
description: "Admin navigation and labels.",
audience: "internal",
items: [
{
key: "admin_nav_settings",
audience: "internal",
values: {
ru: {
base: "Настройки",
fallback: "Настройки",
effective: "Настройки",
override: "",
overridden: false,
},
en: {
base: "Settings",
fallback: "Настройки",
effective: "Settings",
override: "",
overridden: false,
},
},
},
],
},
],
};
}
if (path === "/admin/settings")
return {
ok: true,
+5
View File
@@ -216,6 +216,11 @@ export const DEV_MOCK = {
userAgreementUrl: "https://example.com/agreement",
currency: "RUB",
language: "ru",
languages: [
{ code: "ru", label: "Русский", flag: "🇷🇺", base: true },
{ code: "en", label: "English", flag: "🇬🇧", base: true },
{ code: "uk", label: "Українська", flag: "🇺🇦", base: false },
],
emailAuthEnabled: true,
telegramLoginBotUsername: "preview_bot",
telegramLoginBotId: 1234567890,