Files
PnvPanel/frontend/src/shared/lib/i18n.ts
T
Leonid Pershin 979eddf72e
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 59s
Refactor client update handling to support nullable parameters for name and expiration
- Updated the `UpdateClientAsync` method in `IXuiPanelGateway` to accept nullable parameters for `name` and `expiresAt`, allowing for more flexible client management without unintended modifications.
- Adjusted the `BlockUserCommandHandler`, `UnblockUserCommandHandler`, and other related command handlers to utilize the new nullable parameters, ensuring that client names remain unchanged during block/unblock operations and that expiration dates are managed correctly.
- Enhanced the billing and configuration handling to reflect the new logic for managing client states based on expiration rather than enabling/disabling, improving reliability in client status management.
- Updated tests to cover the new behavior and ensure proper functionality across the application.
2026-07-19 18:58:36 +03:00

1107 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const resources = {
ru: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service портал для VPN-конфигураций',
theme: 'Тема',
language: 'Язык',
light: 'Светлая',
dark: 'Тёмная',
system: 'Системная',
unlimited: 'Без лимита',
auth: {
loginTitle: 'Вход',
registerTitle: 'Регистрация',
userName: 'Имя пользователя',
password: 'Пароль',
submitLogin: 'Войти',
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
goRegister: 'Зарегистрироваться',
goLogin: 'Войти',
loginViaTelegram: 'Войти через Telegram',
invalidCredentials: 'Неверное имя пользователя или пароль.',
duplicateUserName: 'Пользователь с таким именем уже существует.',
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
or: 'или',
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
telegramQrHint:
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
telegramLoginRejected: 'Вход отклонён в Telegram.',
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
},
telegramLinkWarning: {
message: 'Вы не привязали Telegram — без этого вы не будете получать уведомления и не сможете самостоятельно восстановить пароль.',
action: 'Привязать Telegram',
dismiss: 'Скрыть',
},
nav: {
dashboard: 'Мои конфиги',
instructions: 'Инструкции',
news: 'Новости',
support: 'Поддержка',
billing: 'Оплата',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
toggleMenu: 'Меню',
},
activation: {
title: 'Аккаунт не активирован',
description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
commentLabel: 'Комментарий (необязательно)',
submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
retry: 'Повторить',
},
configs: {
title: 'Мои конфиги',
quota: 'Использовано {{used}} из {{max}}',
quotaUnlimited: 'Использовано {{used}}, без лимита',
empty: 'У вас пока нет конфигов. Создайте первый.',
create: 'Создать конфиг',
selectLocation: 'Выберите локацию',
location: 'Локация',
label: 'Метка (необязательно)',
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
billingRequiredNotice: 'Оплата подписки истекла — создание новых конфигов недоступно, пока не продлите доступ.',
goToBilling: 'Перейти к оплате',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
revoked: 'Конфиг отозван.',
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
copied: 'Скопировано.',
copyLink: 'Скопировать ссылку',
loadingLink: 'Загрузка ссылки…',
subscriptionLink: 'Ссылка-подписка (для клиента):',
panelEmail: 'Email в панели: {{email}}',
aggregatedSubscription: 'Общая подписка',
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
status: {
Active: 'Активен',
Disabled: 'Отключён',
Expired: 'Истёк',
LimitReached: 'Лимит исчерпан',
Revoked: 'Отозван',
},
},
billing: {
title: 'Оплата подписки',
notApplicable: 'Биллинг не применяется к вашей роли.',
status: 'Статус оплаты',
suspended: 'Приостановлено',
paidUntil: 'Оплачено до {{date}}',
paidUntilLabel: 'Оплата:',
neverPaid: 'Оплата ещё не производилась.',
neverPaidShort: 'Не оплачено',
expiredShort: 'Истекло',
daysRemaining: '{{count}} дн.',
hoursMinutesRemaining: '{{hours}} ч {{minutes}} мин',
minutesRemaining: '{{count}} мин',
pendingReviewShort: 'На проверке',
pendingReviewHint: 'Оплата отправлена на проверку администратору — доступ не будет приостановлен, пока заявка не решена.',
paidUntilWithRemaining: 'до {{date}} ({{remaining}})',
newRequest: 'Оформить оплату',
period: 'Период',
periods: {
Quarter: '3 месяца',
HalfYear: 'Полгода',
Year: 'Год',
},
createRequest: 'Создать заявку',
requestCreated: 'Заявка создана.',
activeRequest: 'Заявка на оплату',
requestStatus: {
AwaitingPayment: 'Ожидает оплаты',
AwaitingConfirmation: 'На проверке у администратора',
},
requisites: 'Реквизиты для оплаты',
requisitesMissing: 'Реквизиты ещё не настроены администратором.',
sendToTelegram: 'Отправить в Telegram',
requisitesSent: 'Реквизиты отправлены в Telegram.',
telegramNotLinked: 'Сначала привяжите Telegram в настройках.',
iPaid: 'Я оплатил',
markedPaid: 'Отмечено — администратор проверит оплату.',
cancelRequest: 'Отменить',
confirmCancel: 'Отменить заявку на оплату?',
requestCancelled: 'Заявка отменена.',
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
roleChangeTopUp: 'Доплата за смену роли',
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
},
instructions: {
title: 'Инструкции по подключению',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
recommended: 'Рекомендуем',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
news: {
title: 'Новости',
empty: 'Пока нет новостей.',
},
support: {
title: 'Поддержка',
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
requestExtension: 'Попросить о продлении',
requestedDaysLabel: 'Сколько дней нужно',
requestedExtension: 'Запрошено продление на {{days}} дн.',
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
submit: 'Отправить',
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
filesSelected: '{{count}} файл(ов) выбрано',
existingRole: 'Существующая роль',
newRole: 'Новая роль',
selectRole: 'Выберите роль',
roleOption: '{{name}} — конфигов: {{configs}}, IP: {{ip}}',
newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
pricingTitle: 'Ориентировочная стоимость',
pricingQuarter: '3 месяца: {{price}}',
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
reply: 'Ответить',
replyPlaceholder: 'Написать комментарий…',
reopen: 'Переоткрыть',
reopened: 'Тикет переоткрыт.',
lastActivity: 'Последняя активность: {{date}}',
requestedExistingRole: 'Запрошена роль: {{role}}',
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
ExtensionRequest: 'Заявка на продление',
},
status: {
Open: 'Открыт',
Resolved: 'Решён',
Closed: 'Закрыт',
},
},
settings: {
changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль',
currentPasswordRequired: 'Введите текущий пароль.',
currentPasswordInvalid: 'Неверный текущий пароль.',
newPassword: 'Новый пароль',
passwordChanged: 'Пароль изменён.',
changeUserName: 'Сменить логин',
changeUserNameHint: 'Текущий логин: {{current}}.',
newUserName: 'Новый логин',
userNameChanged: 'Логин изменён.',
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
telegramLinkedStatus: 'Привязан',
link: 'Привязать Telegram',
unlink: 'Отвязать',
confirmUnlink: 'Отвязать Telegram от аккаунта?',
telegramLinked: 'Telegram привязан.',
telegramUnlinked: 'Telegram отвязан.',
waitingForLink: 'Ожидание подтверждения в Telegram…',
deleteAccount: 'Удалить аккаунт',
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
confirmDelete: 'Вы уверены? Это действие необратимо.',
confirmDeleteYes: 'Да, удалить',
cancel: 'Отмена',
},
admin: {
prev: 'Назад',
next: 'Вперёд',
tabs: {
overview: 'Обзор',
activation: 'Запросы на активацию',
users: 'Пользователи',
configs: 'Все конфиги',
roles: 'Роли',
pricing: 'Цены',
billing: 'Оплата',
nodes: 'Ноды',
apps: 'Приложения',
instructions: 'Инструкции',
news: 'Новости',
support: 'Тикеты',
audit: 'Аудит',
maintenance: 'Обслуживание',
},
users: {
searchPlaceholder: 'Поиск по имени пользователя',
userName: 'Имя пользователя',
role: 'Роль',
statusLabel: 'Статус',
billingLabel: 'Оплачено до',
manage: 'Управление',
empty: 'Пользователи не найдены.',
total: 'Всего: {{count}}',
status: {
blocked: 'Заблокирован',
active: 'Активен',
pending: 'Не активирован',
},
block: 'Заблокировать',
unblock: 'Разблокировать',
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
blocked: 'Пользователь заблокирован.',
unblocked: 'Пользователь разблокирован.',
roleChanged: 'Роль изменена.',
resetPassword: 'Сбросить пароль',
reset: 'Сбросить',
passwordReset: 'Пароль сброшен.',
configs: 'Конфиги',
delete: 'Удалить пользователя',
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
deleted: 'Пользователь удалён.',
giftDaysPlaceholder: 'Дней',
giftGrant: 'Подарить',
giftGranted: 'Дни подписки подарены пользователю.',
},
configs: {
searchPlaceholder: 'Поиск по email в панели или метке',
owner: 'Владелец',
node: 'Нода',
location: 'Локация',
protocol: 'Протокол',
traffic: 'Трафик',
statusLabel: 'Статус',
allStatuses: 'Все статусы',
created: 'Создан',
empty: 'Конфиги не найдены.',
total: 'Всего: {{count}}',
},
activation: {
empty: 'Нет ожидающих запросов на активацию.',
approved: 'Пользователь активирован.',
rejected: 'Запрос отклонён.',
approve: 'Активировать',
reject: 'Отклонить',
},
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
deleted: 'Роль удалена.',
billingEnabledLabel: 'Включить биллинг для этой роли',
billingBadge: 'биллинг',
},
pricing: {
title: 'Справочная цена конфига',
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
pricePerConfigPerHalfYear: 'Цена за конфиг в месяц / оплата раз в полгода',
pricePerConfigPerHalfYearHint: 'Может быть ниже квартальной (скидка за оплату на полгода вперёд), но итог за полгода не должен быть дешевле итога за квартал.',
halfYearCheaperThanQuarter: 'Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.',
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
pricePerConfigPerYearHint: 'Может быть ниже полугодовой (скидка за годовую оплату), но итог за год не должен быть дешевле итога за полгода.',
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
discountTiersTitle: 'Скидка за объём',
discountTiersHint: 'Роль с квотой конфигов не меньше порога получает указанную скидку от итоговой цены за период. Действует наивысший подходящий порог, скидки не суммируются.',
discountTierMinConfigs: 'От скольки конфигов',
discountTierPercent: 'Скидка, %',
addDiscountTier: 'Добавить ступень',
removeDiscountTier: 'Удалить',
discountTierInvalidMinConfigs: 'Порог — целое число конфигов, не меньше 1.',
discountTierInvalidPercent: 'Скидка — целое число от 1 до 99.',
discountTiersDuplicate: 'Пороги скидочной лесенки не должны повторяться.',
discountTiersNotProgressive: 'Скидка на более высоком пороге не может быть меньше скидки на более низком.',
},
billing: {
settingsTitle: 'Настройки биллинга',
requisitesText: 'Реквизиты для оплаты',
requisitesTextHint: 'Показываются пользователю вместе с заявкой на оплату.',
graceDays: 'Грейс-период (дней)',
graceDaysHint: 'Сколько дней доступ активен после назначения billing-роли, пока не поступит первая оплата.',
defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом',
settingsUpdated: 'Настройки биллинга обновлены.',
requestsTitle: 'Заявки на оплату',
allStatuses: 'Все статусы',
user: 'Пользователь',
period: 'Период',
amount: 'Сумма',
statusLabel: 'Статус',
created: 'Создана',
empty: 'Заявок не найдено.',
confirm: 'Подтвердить',
reject: 'Отклонить',
confirmed: 'Оплата подтверждена.',
rejected: 'Заявка отклонена.',
rejectReasonPrompt: 'Причина отклонения (необязательно):',
status: {
AwaitingPayment: 'Ожидает оплаты',
AwaitingConfirmation: 'На проверке',
Confirmed: 'Подтверждена',
Rejected: 'Отклонена',
Cancelled: 'Отменена',
},
},
nodes: {
create: 'Добавить ноду',
name: 'Название',
baseAddress: 'Адрес панели',
username: 'Логин',
password: 'Пароль',
location: 'Локация',
empty: 'Ноды не добавлены.',
created: 'Нода добавлена.',
updated: 'Нода обновлена.',
deleted: 'Нода удалена.',
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
edit: 'Изменить',
delete: 'Удалить',
probe: 'Проверить',
sync: 'Синхронизировать',
probeSuccess: 'Нода доступна.',
probeFailure: 'Нода недоступна: {{message}}',
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
status: {
Unknown: 'Неизвестно',
Online: 'Онлайн',
Offline: 'Офлайн',
},
enabled: 'Включена',
disabled: 'Отключена',
inbounds: 'Инбаунды',
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
publish: 'Публикация',
published: 'Опубликован',
unpublished: 'Не опубликован',
unavailable: 'Недоступен на панели',
publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя',
allowedRoles: 'Доступно ролям',
isPublishedLabel: 'Опубликовать инбаунд',
optional: 'необязательно',
},
apps: {
create: 'Добавить приложение',
name: 'Название',
downloadUrl: 'Ссылка на скачивание',
os: 'ОС',
description: 'Описание',
iconUrl: 'Ссылка на иконку',
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
recommended: 'Рекомендовано',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
deleted: 'Приложение удалено.',
confirmDelete: 'Удалить приложение из каталога?',
},
instructions: {
introTitle: 'Основная инструкция',
introHint: 'Показывается над вкладками на странице «Инструкции» (Markdown).',
introSaved: 'Инструкция сохранена.',
tabsTitle: 'Дополнительные вкладки',
tabsEmpty: 'Дополнительных вкладок пока нет.',
createTab: 'Добавить вкладку',
tabTitle: 'Название вкладки',
tabBody: 'Текст (Markdown)',
tabCreated: 'Вкладка добавлена.',
tabUpdated: 'Вкладка обновлена.',
tabDeleted: 'Вкладка удалена.',
confirmDeleteTab: 'Удалить вкладку инструкций?',
},
news: {
create: 'Добавить новость',
title: 'Заголовок',
body: 'Текст (Markdown)',
preview: 'Предпросмотр',
empty: 'Новостей пока нет.',
created: 'Новость опубликована.',
updated: 'Новость обновлена.',
deleted: 'Новость удалена.',
confirmDelete: 'Удалить новость?',
},
support: {
empty: 'Обращений пока нет.',
resolve: 'Решено',
resolved: 'Тикет отмечен как решённый.',
close: 'Закрыть',
closed: 'Тикет закрыт.',
approve: 'Одобрить',
approved: 'Заявка одобрена, роль выдана.',
reject: 'Отклонить',
rejected: 'Заявка отклонена.',
},
audit: {
time: 'Время',
action: 'Действие',
target: 'Объект',
source: 'Источник',
empty: 'Журнал аудита пуст.',
},
maintenance: {
closedTickets: {
title: 'Закрытые обращения',
description: 'Удалить все закрытые обращения вместе с перепиской и вложениями (файлами). Действие необратимо.',
action: 'Удалить закрытые обращения',
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
deleted: 'Удалено обращений: {{count}}.',
},
audit: {
title: 'Журнал аудита',
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
olderThan: 'Старше',
days: 'дней',
action: 'Удалить старые записи',
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
deleted: 'Удалено записей аудита: {{count}}.',
},
apps: {
title: 'Отключённые приложения',
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
action: 'Удалить отключённые приложения',
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
deleted: 'Удалено приложений: {{count}}.',
},
factoryReset: {
dangerZone: 'Опасная зона',
title: 'Полный сброс панели',
description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.',
action: 'Сбросить панель',
dialogTitle: 'Полный сброс панели',
warningIntro: 'Будет удалено безвозвратно:',
warningItems: [
'Все пользователи, кроме вашего текущего аккаунта',
'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)',
'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды',
'Все обращения в поддержку вместе с перепиской и вложениями',
'Все новости',
'Весь журнал аудита',
'Все роли, кроме системных admin/user',
'Каталог приложений (будет пересеян из стартового набора)',
],
irreversible: 'Действие необратимо и не может быть отменено.',
confirmPhrase: 'СБРОС',
confirmInputLabel: 'Введите «{{phrase}}» для подтверждения',
confirmButton: 'Сбросить панель безвозвратно',
done: 'Панель сброшена до начального состояния.',
},
},
stats: {
totalUsers: 'Всего пользователей',
activatedUsers: 'Активировано',
pendingActivationRequests: 'Ожидают активации',
totalNodes: 'Всего нод',
onlineNodes: 'Нод онлайн',
totalConfigs: 'Всего конфигов',
activeConfigs: 'Активных конфигов',
totalTraffic: 'Суммарный трафик',
},
},
},
},
en: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service portal for VPN configurations',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
unlimited: 'Unlimited',
auth: {
loginTitle: 'Log in',
registerTitle: 'Register',
userName: 'Username',
password: 'Password',
submitLogin: 'Log in',
submitRegister: 'Register',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
goRegister: 'Register',
goLogin: 'Log in',
loginViaTelegram: 'Log in via Telegram',
invalidCredentials: 'Invalid username or password.',
duplicateUserName: 'A user with this name already exists.',
genericError: 'Something went wrong. Please try again.',
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
or: 'or',
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
telegramQrHint:
'Follow the link or scan the QR code — it opens the bot in Telegram. If this is your first time, tap "Start".',
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
telegramLoginRejected: 'Login was rejected in Telegram.',
telegramLoginExpired: 'The request expired, please try again.',
},
telegramLinkWarning: {
message: "You haven't linked Telegram — without it you won't receive notifications and won't be able to recover your password yourself.",
action: 'Link Telegram',
dismiss: 'Dismiss',
},
nav: {
dashboard: 'My configs',
instructions: 'Instructions',
news: 'News',
support: 'Support',
billing: 'Billing',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
toggleMenu: 'Menu',
},
activation: {
title: 'Account not activated',
description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
commentLabel: 'Comment (optional)',
submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.',
retry: 'Retry',
},
configs: {
title: 'My configs',
quota: 'Used {{used}} of {{max}}',
quotaUnlimited: 'Used {{used}}, unlimited',
empty: "You don't have any configs yet. Create your first one.",
create: 'Create config',
selectLocation: 'Select location',
location: 'Location',
label: 'Label (optional)',
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
billingRequiredNotice: 'Your subscription has expired — creating new configs is unavailable until you renew.',
goToBilling: 'Go to payment',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
revoked: 'Config revoked.',
confirmRevoke: 'Revoke this config? This cannot be undone.',
copied: 'Copied.',
copyLink: 'Copy link',
loadingLink: 'Loading link…',
subscriptionLink: 'Subscription link (for the client app):',
panelEmail: 'Panel email: {{email}}',
aggregatedSubscription: 'Aggregated subscription',
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
status: {
Active: 'Active',
Disabled: 'Disabled',
Expired: 'Expired',
LimitReached: 'Limit reached',
Revoked: 'Revoked',
},
},
billing: {
title: 'Subscription billing',
notApplicable: 'Billing does not apply to your role.',
status: 'Payment status',
suspended: 'Suspended',
paidUntil: 'Paid until {{date}}',
paidUntilLabel: 'Payment:',
neverPaid: 'No payment has been made yet.',
neverPaidShort: 'Not paid',
expiredShort: 'Expired',
daysRemaining: '{{count}}d',
hoursMinutesRemaining: '{{hours}}h {{minutes}}m',
minutesRemaining: '{{count}}m',
pendingReviewShort: 'Under review',
pendingReviewHint: 'Payment sent for admin review — access will not be suspended until the request is decided.',
paidUntilWithRemaining: 'until {{date}} ({{remaining}})',
newRequest: 'Set up payment',
period: 'Period',
periods: {
Quarter: '3 months',
HalfYear: 'Half a year',
Year: 'A year',
},
createRequest: 'Create request',
requestCreated: 'Request created.',
activeRequest: 'Payment request',
requestStatus: {
AwaitingPayment: 'Awaiting payment',
AwaitingConfirmation: 'Under admin review',
},
requisites: 'Payment details',
requisitesMissing: 'The administrator has not set up payment details yet.',
sendToTelegram: 'Send to Telegram',
requisitesSent: 'Payment details sent to Telegram.',
telegramNotLinked: 'Link Telegram in settings first.',
iPaid: 'I paid',
markedPaid: 'Marked — the administrator will verify the payment.',
cancelRequest: 'Cancel',
confirmCancel: 'Cancel this payment request?',
requestCancelled: 'Request cancelled.',
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
roleChangeTopUp: 'Role change top-up',
roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
},
instructions: {
title: 'Connection instructions',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
recommended: 'Recommended',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
news: {
title: 'News',
empty: 'No news yet.',
},
support: {
title: 'Support',
empty: 'You have no tickets yet.',
reportBug: 'Report a bug',
requestRole: 'Request a role',
requestExtension: 'Request an extension',
requestedDaysLabel: 'How many days you need',
requestedExtension: 'Requested a {{days}}-day extension',
extensionRequestPending: 'You already have a pending extension request.',
submit: 'Submit',
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
filesSelected: '{{count}} file(s) selected',
existingRole: 'Existing role',
newRole: 'New role',
selectRole: 'Select a role',
roleOption: '{{name}} — configs: {{configs}}, IP: {{ip}}',
newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
pricingTitle: 'Estimated cost',
pricingQuarter: '3 months: {{price}}',
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
reply: 'Reply',
replyPlaceholder: 'Write a comment…',
reopen: 'Reopen',
reopened: 'Ticket reopened.',
lastActivity: 'Last activity: {{date}}',
requestedExistingRole: 'Requested role: {{role}}',
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
ExtensionRequest: 'Extension request',
},
status: {
Open: 'Open',
Resolved: 'Resolved',
Closed: 'Closed',
},
},
settings: {
changePassword: 'Change password',
currentPassword: 'Current password',
currentPasswordRequired: 'Enter your current password.',
currentPasswordInvalid: 'Current password is incorrect.',
newPassword: 'New password',
passwordChanged: 'Password changed.',
changeUserName: 'Change username',
changeUserNameHint: 'Current username: {{current}}.',
newUserName: 'New username',
userNameChanged: 'Username changed.',
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
telegramLinkedStatus: 'Linked',
link: 'Link Telegram',
unlink: 'Unlink',
confirmUnlink: 'Unlink Telegram from your account?',
telegramLinked: 'Telegram linked.',
telegramUnlinked: 'Telegram unlinked.',
waitingForLink: 'Waiting for confirmation in Telegram…',
deleteAccount: 'Delete account',
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
confirmDelete: 'Are you sure? This cannot be undone.',
confirmDeleteYes: 'Yes, delete',
cancel: 'Cancel',
},
admin: {
prev: 'Previous',
next: 'Next',
tabs: {
overview: 'Overview',
activation: 'Activation requests',
users: 'Users',
configs: 'All configs',
roles: 'Roles',
pricing: 'Pricing',
billing: 'Billing',
nodes: 'Nodes',
apps: 'Apps',
instructions: 'Instructions',
news: 'News',
support: 'Tickets',
audit: 'Audit',
maintenance: 'Maintenance',
},
users: {
searchPlaceholder: 'Search by username',
userName: 'Username',
role: 'Role',
statusLabel: 'Status',
billingLabel: 'Paid until',
manage: 'Manage',
empty: 'No users found.',
total: 'Total: {{count}}',
status: {
blocked: 'Blocked',
active: 'Active',
pending: 'Not activated',
},
block: 'Block',
unblock: 'Unblock',
confirmBlock: 'Block this user? All their configs will be disabled.',
blocked: 'User blocked.',
unblocked: 'User unblocked.',
roleChanged: 'Role changed.',
resetPassword: 'Reset password',
reset: 'Reset',
passwordReset: 'Password reset.',
configs: 'Configs',
delete: 'Delete user',
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
deleted: 'User deleted.',
giftDaysPlaceholder: 'Days',
giftGrant: 'Grant',
giftGranted: 'Subscription days granted to the user.',
},
configs: {
searchPlaceholder: 'Search by panel email or label',
owner: 'Owner',
node: 'Node',
location: 'Location',
protocol: 'Protocol',
traffic: 'Traffic',
statusLabel: 'Status',
allStatuses: 'All statuses',
created: 'Created',
empty: 'No configs found.',
total: 'Total: {{count}}',
},
activation: {
empty: 'No pending activation requests.',
approved: 'User activated.',
rejected: 'Request rejected.',
approve: 'Approve',
reject: 'Reject',
},
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '1 = unlimited. Applies to new configs only.',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
deleted: 'Role deleted.',
billingEnabledLabel: 'Enable billing for this role',
billingBadge: 'billing',
},
pricing: {
title: 'Reference config price',
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
pricePerConfigPerHalfYear: 'Price per config per month / billed every 6 months',
pricePerConfigPerHalfYearHint: 'Can be lower than the quarterly rate (discount for a 6-month prepayment), but the 6-month total must not be cheaper than the 3-month total.',
halfYearCheaperThanQuarter: 'The 6-month price (over 6 months) cannot be lower than the 3-month price.',
pricePerConfigPerYear: 'Price per config per month / billed yearly',
pricePerConfigPerYearHint: 'Can be lower than the 6-month rate (annual discount), but the yearly total must not be cheaper than the 6-month total.',
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
discountTiersTitle: 'Volume discount',
discountTiersHint: 'A role with a config quota at or above the threshold gets the listed discount off the period total. The highest applicable threshold wins — discounts do not stack.',
discountTierMinConfigs: 'From how many configs',
discountTierPercent: 'Discount, %',
addDiscountTier: 'Add tier',
removeDiscountTier: 'Remove',
discountTierInvalidMinConfigs: 'Threshold must be a whole number of configs, at least 1.',
discountTierInvalidPercent: 'Discount must be a whole number from 1 to 99.',
discountTiersDuplicate: 'Discount tier thresholds cannot repeat.',
discountTiersNotProgressive: 'A higher threshold cannot have a smaller discount than a lower one.',
},
billing: {
settingsTitle: 'Billing settings',
requisitesText: 'Payment details',
requisitesTextHint: 'Shown to the user alongside their payment request.',
graceDays: 'Grace period (days)',
graceDaysHint: 'How many days access stays active after a billing role is assigned, before the first payment is due.',
defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled',
settingsUpdated: 'Billing settings updated.',
requestsTitle: 'Payment requests',
allStatuses: 'All statuses',
user: 'User',
period: 'Period',
amount: 'Amount',
statusLabel: 'Status',
created: 'Created',
empty: 'No requests found.',
confirm: 'Confirm',
reject: 'Reject',
confirmed: 'Payment confirmed.',
rejected: 'Request rejected.',
rejectReasonPrompt: 'Rejection reason (optional):',
status: {
AwaitingPayment: 'Awaiting payment',
AwaitingConfirmation: 'Under review',
Confirmed: 'Confirmed',
Rejected: 'Rejected',
Cancelled: 'Cancelled',
},
},
nodes: {
create: 'Add node',
name: 'Name',
baseAddress: 'Panel address',
username: 'Username',
password: 'Password',
location: 'Location',
empty: 'No nodes added yet.',
created: 'Node added.',
updated: 'Node updated.',
deleted: 'Node deleted.',
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
edit: 'Edit',
delete: 'Delete',
probe: 'Probe',
sync: 'Sync',
probeSuccess: 'Node is reachable.',
probeFailure: 'Node unreachable: {{message}}',
syncSuccess: 'Synced inbounds: {{count}}',
status: {
Unknown: 'Unknown',
Online: 'Online',
Offline: 'Offline',
},
enabled: 'Enabled',
disabled: 'Disabled',
inbounds: 'Inbounds',
noInbounds: 'No inbounds found — click "Sync".',
publish: 'Publishing',
published: 'Published',
unpublished: 'Not published',
unavailable: 'Unavailable on panel',
publishSaved: 'Publishing settings saved.',
displayName: 'Display name',
allowedRoles: 'Allowed for roles',
isPublishedLabel: 'Publish inbound',
optional: 'optional',
},
apps: {
create: 'Add app',
name: 'Name',
downloadUrl: 'Download link',
os: 'OS',
description: 'Description',
iconUrl: 'Icon URL',
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
recommended: 'Recommended',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',
deleted: 'App deleted.',
confirmDelete: 'Remove this app from the catalog?',
},
instructions: {
introTitle: 'Main instruction',
introHint: 'Shown above the tabs on the Instructions page (Markdown).',
introSaved: 'Instruction saved.',
tabsTitle: 'Additional tabs',
tabsEmpty: 'No additional tabs yet.',
createTab: 'Add tab',
tabTitle: 'Tab title',
tabBody: 'Body (Markdown)',
tabCreated: 'Tab added.',
tabUpdated: 'Tab updated.',
tabDeleted: 'Tab deleted.',
confirmDeleteTab: 'Delete this instruction tab?',
},
news: {
create: 'Add post',
title: 'Title',
body: 'Body (Markdown)',
preview: 'Preview',
empty: 'No news yet.',
created: 'Post published.',
updated: 'Post updated.',
deleted: 'Post deleted.',
confirmDelete: 'Delete this post?',
},
support: {
empty: 'No tickets yet.',
resolve: 'Resolve',
resolved: 'Ticket marked as resolved.',
close: 'Close',
closed: 'Ticket closed.',
approve: 'Approve',
approved: 'Request approved, role granted.',
reject: 'Reject',
rejected: 'Request rejected.',
},
audit: {
time: 'Time',
action: 'Action',
target: 'Target',
source: 'Source',
empty: 'The audit log is empty.',
},
maintenance: {
closedTickets: {
title: 'Closed tickets',
description: 'Delete all closed tickets along with their comments and attachments (files). This cannot be undone.',
action: 'Delete closed tickets',
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
deleted: 'Deleted tickets: {{count}}.',
},
audit: {
title: 'Audit log',
description: 'Delete audit log entries older than the given number of days. This cannot be undone.',
olderThan: 'Older than',
days: 'days',
action: 'Delete old entries',
confirm: 'Delete audit entries older than {{days}} days? This cannot be undone.',
deleted: 'Deleted audit entries: {{count}}.',
},
apps: {
title: 'Disabled apps',
description: 'Delete all disabled apps from the catalog. This cannot be undone.',
action: 'Delete disabled apps',
confirm: 'Delete all disabled apps? This cannot be undone.',
deleted: 'Deleted apps: {{count}}.',
},
factoryReset: {
dangerZone: 'Danger zone',
title: 'Full panel reset',
description: 'Return the panel to a freshly-deployed state — deletes all users, configs, nodes and data. Your current account is kept.',
action: 'Reset panel',
dialogTitle: 'Full panel reset',
warningIntro: 'This will permanently delete:',
warningItems: [
'All users except your current account',
'All VPN configs (revoked on the 3x-ui nodes first)',
'All nodes and inbounds — 3x-ui server registrations and stored credentials',
'All support tickets with their comments and attachments',
'All news posts',
'The entire audit log',
'All roles except the system admin/user roles',
'The app catalog (will be re-seeded from the default set)',
],
irreversible: 'This cannot be undone.',
confirmPhrase: 'RESET',
confirmInputLabel: 'Type "{{phrase}}" to confirm',
confirmButton: 'Reset the panel permanently',
done: 'The panel has been reset to its initial state.',
},
},
stats: {
totalUsers: 'Total users',
activatedUsers: 'Activated',
pendingActivationRequests: 'Pending activation',
totalNodes: 'Total nodes',
onlineNodes: 'Nodes online',
totalConfigs: 'Total configs',
activeConfigs: 'Active configs',
totalTraffic: 'Total traffic',
},
},
},
},
}
const STORAGE_KEY = 'pnv-lang'
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
void i18n.use(initReactI18next).init({
resources,
lng: stored ?? 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
})
export function setLanguage(lng: string) {
localStorage.setItem(STORAGE_KEY, lng)
void i18n.changeLanguage(lng)
}
export default i18n