feat: add runtime locale overrides
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
Home,
|
||||
Info,
|
||||
Key,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Lock,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user