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