52 Commits

Author SHA1 Message Date
github-actions[bot]
b4877965a1 Merge remote-tracking branch 'upstream/main' 2026-08-21 06:35:02 +00:00
Egor
2192484b01 Merge pull request #555 from BEDOLAGA-DEV/release-please--branches--main--components--cabinet-frontend
chore(main): release 1.66.0
2026-08-20 12:40:40 +03:00
github-actions[bot]
8a7ef9ace0 chore(main): release 1.66.0 2026-08-20 09:39:10 +00:00
Egor
6474b28cef Merge pull request #554 from BEDOLAGA-DEV/dev
Dev
2026-08-20 12:38:16 +03:00
c0mrade
a451a129fe fix(geocheck): убрать скачивание отчёта в Telegram Mini App
В webview Telegram атрибут `download` игнорируется: вместо сохранения файла
webview уходит на blob-ссылку и подменяет собой Mini App, вернуться можно
только кнопкой «Назад». То есть кнопка не просто не работала, а выкидывала
администратора из приложения.

Штатный путь тут не подходит. `downloadFile` из Bot API 8.0 требует HTTPS-URL
файла с заголовками `Content-Disposition: attachment` и CORS для
web.telegram.org, а отчёт приходит base64 внутри JSON-ответа бота — ссылки на
него не существует. Отдать её можно только новым эндпоинтом бота, причём
подписанным: `downloadFile` скачивает URL сам, без заголовка Authorization.
Это отдельная работа, а не правка кнопки.

Пока кнопка скрыта в Mini App тем же признаком платформы, что и полный экран.
Данные из отчёта там по-прежнему достаются копированием JSON, а на десктопе
скачивание работает как раньше.

Проверено рендером обеих платформ: в Telegram тулбар — отчёт, JSON,
копирование, перезапуск, зум; в вебе к ним добавляются полный экран и
скачивание.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:11:58 +03:00
c0mrade
5c3031f5f5 fix(geocheck): скелетон до отрисовки отчёта и сброс зума на новом отчёте
Отчёт весит сотни килобайт вместе со встроенным шрифтом, поэтому между
приходом данных и первой отрисовкой есть заметная пауза. Всё это время на
экране была пустая тёмная коробка, зато с готовыми кнопками зума, которым
нечего масштабировать. Теперь до отрисовки показывается скелетон, повторяющий
ритм отчёта, а панель зума появляется вместе с картинкой.

Перезапуск проверки наследовал зум и сдвиг от предыдущего отчёта. Новый отчёт
должен открываться в исходном масштабе, поэтому его подпись входит в ключ
просмотрщика.

Подпись — хеш всей строки, посчитанный один раз на отчёт. Первая попытка
брала длину и хвост base64, и этого оказалось мало: отчёты одного узла
кончаются одинаково, подписи совпадали, зум не сбрасывался. Поймано
проверкой, а не рассуждением.

Готовность хранится как «какая картинка отрисована», а не булевым флагом:
тогда новый отчёт автоматически считается незагруженным и сбрасывающий
эффект не нужен.

Проверено в браузере: при задержке картинки — скелетон есть, панели зума нет;
после загрузки наоборот. Зум со сдвигом после перезапуска возвращается к
исходному преобразованию, дважды подряд.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:58:54 +03:00
c0mrade
5db1ffccf6 fix(geocheck): убрать полноэкранный режим в Telegram Mini App
В Mini App окно Telegram и так занимает экран целиком, поэтому режим ничего
не давал: он лишь снимал отступы модалки и лез под шапку и home indicator.
Кнопка полезна на десктопе, где отчёт показывается в центрированном окне.

Модалка определяет платформу через готовый useIsTelegram и передаёт отчёту
canFullscreen; при выключенном флаге кнопка не рендерится, а запрошенный
режим не применяется, даже если состояние осталось от прошлого открытия.

Проверено рендером на обеих платформах в одном дереве: telegram —
canFullscreen=false и 8 кнопок в тулбаре, web — true и 9. Разница ровно в
одной кнопке.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:33:56 +03:00
c0mrade
debe26ae35 fix(geocheck): сквозные клики через портал и safe-зоны в полноэкранном режиме
Три поломки, замеченные на dev.

Клики внутри модалки срабатывали и на том, что под ней. Модалка рендерилась
внутри `<div onClick>` карточки ноды: портал уносит её в document.body только
по DOM, а события React прогоняет по дереву компонентов, поэтому каждый клик
всплывал в обработчик аккордеона. Теперь модалка — сосед кликабельного блока,
а не его потомок. Проверено A/B на настоящей модалке: в старой структуре клик
по вкладке внутри модалки поднимал счётчик карточки до 1, в новой остаётся 0.
По существу правка в 6 строк, остальное в дифе — переотступы от обёртки.

В полноэкранном режиме отступы обнулялись целиком, и в Mini App панель
инструментов уезжала под шапку Telegram, а кнопки зума — под home indicator.
Теперь safe-зоны соблюдаются и в полноэкранном режиме, просто без лишнего
сантиметра. Замер при отступах 96/34 на экране 844: диалог занимает 96..810,
низ зум-бара на 784 — оба внутри безопасной зоны, было наоборот.

Высота диалога считалась от `100dvh` мимо safe-зон, из-за чего он мог вылезти
за них даже в обычном режиме. Заменено на `max-h-full`: отступы контейнера
уже урезали доступное место.

Заодно в полноэкранном режиме шапка ужата в одну строку с именем ноды —
вертикальное место ровно то, ради чего его включают.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:23:30 +03:00
c0mrade
28e25a9292 feat(dashboard): подключение устройства прикреплено к карточке подписки
На главной подключение жило отдельной плиткой под карточкой, и показывалось
только когда подписка одна: при нескольких «выбор устройства неоднозначен».
Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и
покупка новой, — из которых вторая ровней не была. При нескольких подписках
подключение с главной пропадало вовсе.

Теперь это подвал самой карточки: тело ведёт в подписку, подвал — в
подключение. Одна карточка на подписку при любом их числе, отдельных плиток
нет. Карточка снаружи перестала быть `<button>` — вложенные кнопки невалидны
и ведут себя в браузерах непредсказуемо, поэтому это обёртка с двумя зонами
нажатия.

Подвал включается пропом `connect` и задан только на главной: список «Все
подписки» остаётся перечнем без действий. Где подвал есть, из строки
статистики убран счётчик лимита устройств — он дублировал бы счётчик подвала.

Акцентом подсвечивается только подписка без единого устройства: там действие
и нужно. У остальных подвал приглушён и не спорит с карточкой.

Исчерпанный лимит не блокирует кнопку, а ведёт в разбор: сколько слотов даёт
тариф, какие устройства их занимают, отключить лишнее либо добавить слоты.
Докупка скрыта на тестовой подписке и при безлимите — те же условия, что у
блока докупки на странице подписки, — и тогда текст не обещает того, чего
сделать нельзя.

Пока счётчик устройств не пришёл, подвал показывает скелетон, а не догадку о
состоянии. Его высоту задаёт невидимый спан с той же типографикой: подбор
числом даёт дробные пиксели строчного бокса, и карточка дёргается. Проверено
замером — сдвиг 0px на всех состояниях.

ResponsiveSheet: на телефоне нижний шит, с 640px обычное центрированное окно.
Шит на десктопе — плашка у нижнего края с ручкой перетаскивания, бессмысленной
для мыши. Компонент общий, остальные шиты кабинета смогут его переиспользовать.

ConnectDeviceTile не тронут и продолжает жить в карточке подписки одиночного
тарифа вместе с якорем онбординга.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:55:27 +03:00
c0mrade
b4e1de6152 feat(remnawave): GeoCheck ноды в админке — запуск проверки и просмотр отчёта
Кнопка в карточке ноды открывает модалку: выбор маршрута (по умолчанию /
IP-адрес / интерфейс) с подсказками из данных ноды, ожидание, затем отчёт
картинкой либо тем же отчётом в JSON. Есть копирование JSON, скачивание
SVG, повтор проверки и полноэкранный режим.

Кнопка показывается только для узлов 3.3.0+ (versions.node) и только для
подключённых — иначе админ упирался бы в ошибку панели.

Отчёт вставляется как <img src="data:...">, а не сырым SVG в разметку: он
несёт встроенный моноширинный шрифт и <style>, на которых держится
выравнивание колонок, и санитайзер их выбрасывает. В <img> SVG рисуется в
изолированном контексте без скриптов и внешних запросов — и безопаснее, и
вернее по виду.

Зум и панорамирование — react-zoom-pan-pinch с той же конфигурацией, что в
самой панели Remnawave. Своя реализация обязательна: index.html задаёт
user-scalable=no, поэтому браузерного пинча нет ни в мобильном вебе, ни в
Mini App. Колесо намеренно не масштабирует, иначе прокрутка над отчётом
превращается в зум.

В отличие от панели стартовый масштаб считается от читаемой ширины: на
телефоне «вписано по ширине» превращает отчёт в серую рябь. На 390px это
2.42x с панорамированием, на десктопе — 1x. При смене ширины (полный
экран, поворот) масштаб пересчитывается.

Опрос задачи с потолком в три минуты — иначе забытая открытой модалка
опрашивала бы панель бесконечно. Пока проверка идёт, модалка не
закрывается: задача уже поставлена, терять её результат молча хуже.

Заодно в AdminRemnawave.tsx осели автоправки biome (import type,
Math.pow -> **) — файл всё равно правился, они механические.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:01:05 +03:00
Fringg
f81af401f7 fix(auth): читать initData из моста Telegram, а не только из кэша SDK
retrieveRawInitData() ищет параметры запуска по цепочке location.href ->
запись performance о навигации -> кэш tapps/launchParams в sessionStorage,
записывая удачный результат обратно. Последние два источника привязаны к
документу, а не к текущему запуску мини-аппы: как только SPA сменила
маршрут и hash с tgWebAppData ушёл из адреса, остаются данные ПЕРВОГО
запуска в этой сессии WebView. На iOS WebView переживает переоткрытия, и
SDK молча отдаёт старую копию.

Бэкенд принимает initData с auth_date не старше 30 суток, поэтому
застрявшая копия рано или поздно пересекает порог и вход отваливается с
«Invalid or expired Telegram authentication data».

Спрашиваем ещё и window.Telegram.WebApp.initData (официальный мост,
telegram-web-app.js из index.html) и берём вариант со свежим auth_date:
какой источник протух, зависит от платформы, «самый свежий» верен всегда.
Все три места чтения initData сведены в один модуль.
2026-08-19 02:00:41 +03:00
Fringg
acbffa4e17 fix(landing): не оставлять контакт из ссылки в адресной строке
Лендинг поднимает Яндекс.Метрику с webvisor, а она пишет URL страницы и
запись сессии. Личный email или @username из ?contact= уезжал бы в
аналитику, в Referer при переходе на оплату, в историю браузера и в логи
сервера. Читаем параметр и сразу убираем его через history.replaceState,
сохраняя остальные параметры и хеш, — тем же приёмом, что применяет
captureCampaignFromUrl().

Чтение и очистка вынесены в src/utils/contactPrefill.ts: в компоненте это
были бы непокрытые строки, а обращение с персональными данными хочется
иметь под тестами.
2026-08-18 15:11:49 +03:00
Fringg
8d0c33bb08 Merge pull request #552 from DarkWood312/main
feat(landing): автозаполнение поля контакта из параметра URL (?contact=...)
2026-08-18 15:11:35 +03:00
Aleksandr
cb309ead51 now able to pass username | email in params 2026-08-17 23:18:48 +03:00
Fringg
714ece8250 test(tickets): закрепить перенос длинного текста в сообщении
Правка презентационная и покрытия не имела: класс break-words легко
потерять при переборе классов, а симптом невидим — контейнер сообщений
скроллится по горизонтали, но полосу прячет .scrollbar-hide.

Замер в браузере на ширине 1280px подтверждает цену потери: без
break-words контейнер 803px против содержимого 3179px, то есть 2376px
текста недостижимы. Класс проверяется рядом с самим выводом тела
сообщения во всех трёх рендерерах.
2026-08-17 21:54:25 +03:00
Fringg
eede5ffdfd Merge pull request #551 from Chara-Freedom/fix/ticket-long-message-wrap
fix(tickets): длинный текст в сообщении уезжал за пределы карточки
2026-08-17 21:54:25 +03:00
Fringg
8e9bb0dc23 test(landing): закрепить отправку слага кампании при гостевой покупке
Покрытия у слага не было вовсе. Пиним два инварианта: слаг попадает в тело
запроса покупки и читается БЕЗ потребления — очистка на этом шаге лишила бы
привязки того же клиента, если он позже войдёт в кабинет.

Компоненты в репозитории не рендерятся (vitest на node, без jsdom), поэтому
отправка фиксируется по исходнику; поведение самих хелперов проверяется
обычными тестами с подменённым localStorage.
2026-08-17 21:23:35 +03:00
Fringg
cfc2ab2f7f Merge pull request #549 from AirP0WeR/feat/campaign-slug-on-guest-purchase
feat(landing): отправлять слаг рекламной кампании при гостевой покупке
2026-08-17 21:23:35 +03:00
rabcristine
2ba6fc0f6f fix(tickets): длинный текст в сообщении уезжал за пределы карточки
Тело сообщения рендерилось с whitespace-pre-wrap, но без break-words:
pre-wrap переносит только по пробелам, поэтому неразрывный токен —
ссылка на подписку, ключ, base64 — вылезал за пузырь. Контейнер
сообщений имеет scrollbar-hide и overflow-y-auto, из-за чего браузер
вычисляет overflow-x в auto, а полосу прокрутки прячет CSS: хвост такого
сообщения на десктопе было не достать.

Приведено к конвенции проекта для пользовательского текста
(BroadcastPreview.tsx): whitespace-pre-wrap break-words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:26 +03:00
airp0wer
d4379fce25 feat(landing): отправлять слаг рекламной кампании при гостевой покупке
Покупка гостем на лендинге не проходит через auth-флоу, в котором кампания
привязывается обычно, поэтому клиент, пришедший по рекламной ссылке, не
попадал в статистику кампании и не получал её бонус.

Слаг уже лежит в localStorage после захода по рекламной ссылке
(captureCampaignFromUrl вызывается на любом маршруте) — теперь он уезжает в
запрос покупки. Читаем без потребления: гость может позже войти в кабинет, и
там привязка должна остаться возможной.

Требует поддержки campaign_slug в PurchaseRequest на стороне бота.
2026-08-12 15:08:32 +03:00
Fringg
893b4020bd Merge pull request #548 from AirP0WeR/fix/support-ticket-errors 2026-08-11 13:44:30 +03:00
Fringg
533fc3073f test(support): закрепить привязку onError к мутациям тикетов
Дополнение к PR #548 при мерже. Код менять не пришлось — добавлены три теста.

Решение «это 409» автор вынес в утилиту и покрыл хорошо, а вот сама привязка
onError к мутациям осталась непокрытой — при том, что её отсутствие и есть
исходный баг: форма молчала, человек жал «Отправить» повторно. Проверил
мутациями: снятие onError у createMutation, снятие onError у replyMutation и
удаление обновления списка тикетов в ветке 409 набор не роняли.

Окружения для рендера компонентов в репозитории нет (vitest настроен на node,
без jsdom), поэтому закрепил структурно, по исходнику — приём тот же, что уже
используется в бот-репозитории. Крудовато, но ловит ровно ту регрессию, ради
которой PR и написан.

Остальное проверено, правок не потребовалось. Несущее допущение верно: 409 в
app/cabinet/routes/tickets.py встречается ровно один раз и именно про открытый
тикет, так что матч по статусу вместо текста detail — правильный выбор (текст
приходит только по-английски, а кабинет и бот версионируются раздельно).
Переименование rateLimitError → formError сквозное, хвостов не осталось; формы
create и reply взаимоисключающие, состояние сбрасывается на всех переходах.
Ключи support.errors.* есть во всех четырёх локалях. Шесть предупреждений biome
в Support.tsx — предсуществующие, столько же на чистом dev.
2026-08-11 13:44:00 +03:00
Fringg
a82950126e Merge remote-tracking branch 'origin/dev' into pr-548 2026-08-11 13:40:38 +03:00
airp0wer
d88928912a fix(support): показывать пользователю отказ бэка при создании тикета
`POST /cabinet/tickets` отвечает 409 «You already have an open ticket»,
если у пользователя уже есть незакрытый тикет, но `createMutation` не имела
`onError` — форма молча оставалась на месте. Со стороны это выглядело как
«кнопка Отправить не работает»: пользователь жал её повторно и уходил в
поддержку с вопросом, почему обращение не создаётся. Тем же молчанием
отвечал и ответ в тикет (403 при блокировке в поддержке, 400 на закрытом
тикете).

Обе мутации теперь пишут текст ошибки в состояние формы — тот же блок,
в котором уже показывался клиентский rate-limit (`rateLimitError`
переименован в `formError`, состояние по смыслу общее). Для 409 показываем
локализованную подсказку и обновляем список тикетов: открытый тикет мог
появиться в другой сессии (бот, второе устройство), и после инвалидации
пользователю есть куда перейти. Остальные коды отдают `detail` бэка через
`getApiErrorMessage` с локализованным фолбэком.

409 ловим по статусу, а не по тексту `detail`: он приходит только
по-английски, а кабинет и бот версионируются раздельно.
2026-08-10 17:38:47 +03:00
Fringg
9a12e002fd Merge pull request #544 from Case211/feat/home-connect-device 2026-08-07 15:27:29 +03:00
Fringg
39b9b8e1d2 fix(dashboard): счётчик устройств в плитке на главной
Дополнения к PR #544 при мерже.

Плитка на главной берёт число подключённых устройств из запроса ['devices'],
а он выключен ровно там, где плитка живёт: `enabled: !!subscription &&
!isMultiTariff`, тогда как блок с плиткой рендерится в мультитарифной ветке,
где `subscription` всегда null (свой запрос отключён по !isMultiTariff).
То есть счётчик показывал бы «0 из N» всегда, лимит устройств не срабатывал бы
никогда, а точки-индикаторы стояли бы пустыми — при том, что счётчик и есть
смысл плитки.

Добавлен запрос по конкретной подписке: getDevices принимает subscription_id,
ключ ['devices', id] — тот же, что на странице подписки, поэтому кэш общий и
лишнего похода в сеть при переходе туда не будет. Заодно вынес выбор
единственной подписки в переменную, чтобы условие показа и данные плитки не
разъезжались.

Отдельно поправлен потерянный пробел в className: `duration-300${...}` склеивал
класс в `duration-300cursor-not-allowed`, из-за чего оформление заблокированной
плитки не применялось. Баг приехал вместе с переносом из SubscriptionCardActive
(там он с самого начала), но раз компонент всё равно выделяется — чиню здесь.
2026-08-07 15:27:17 +03:00
Fringg
9532819fc6 Merge remote-tracking branch 'origin/dev' into pr-544 2026-08-07 15:24:13 +03:00
Fringg
3995ed8a28 Merge pull request #543 from Case211/feat/promocode-traffic-bonus 2026-08-06 02:53:11 +03:00
Fringg
29c9ed1a40 fix(promocodes): чекбоксы набора по значениям, трафик в списке
Дополнения к PR #543 при мерже.

С появлением трафика тип balance_and_days перестал означать «есть и баланс, и
дни»: теперь он стоит у любого набора с трафиком, в том числе с нулевым
балансом и нулём дней. Две поверхности этого не учитывали.

Форма редактирования поднимала галочки «Баланс» и «Дни» по ТИПУ. Код с одним
трафиком открывался с двумя чужими галочками на нулях, валидация требовала
«больше 0», и Сохранить не работало — админ не мог поправить даже число
активаций, ничего не меняя по сути. Хуже того, подсказка прямо предлагает
вписать сумму: сделав это, админ молча превращал код на 50 ГБ в код на 50 ГБ
плюс 100 рублей. Галочки теперь выводятся по значениям — ровно так, как сам
автор уже сделал для трафика строкой ниже.

Список промокодов печатал составляющие тоже по типу, поэтому код с одним
трафиком показывался как «+0 ₽ +0 дн.» и ни слова про гигабайты: админ при
разборе списка видел код, который якобы не даёт ничего. Теперь каждая
составляющая показывается по своему значению, и добавлена строка трафика —
ключ form.gb автор уже завёл во всех четырёх локалях.

Дробный ввод не трогал: поведение общее с полями баланса и дней и от этого PR
не зависит.
2026-08-06 02:53:02 +03:00
Fringg
1883ec9340 Merge remote-tracking branch 'origin/dev' into pr-543 2026-08-06 02:35:35 +03:00
Fringg
04d3990749 Merge pull request #542 from Case211/feat/admin-delete-subscription 2026-08-06 02:25:10 +03:00
Fringg
415a76f7cf fix(users): показывать причину отказа при удалении подписки
Дополнение к PR #542 при мерже.

Обработчик глушил ошибку целиком и показывал общее «Ошибка». Между тем сервер
отвечает ровно теми словами, которые админу и нужны: открытый временный доступ
(409, «сначала заверши или восстанови grace»), активная платная без force (409),
подписки нет (404). Первый случай автор сам описал в PR как штатный сценарий —
и именно он выглядел как сломанная кнопка. Прокинул getApiErrorMessage, он для
этого в репозитории и заведён и уже используется в восьми других местах.

Логику force не трогал: она совпадает с сервером. Сериализуемое is_active
собирается в _build_subscription_info как `status == active AND end_date > now`,
то есть в точности серверное свойство Subscription.is_active, по которому и
стоит защита. Разъехаться значения могут только в безопасную сторону: подписка
успела истечь между загрузкой страницы и нажатием — тогда защиты нет и на
сервере.
2026-08-06 02:25:02 +03:00
Fringg
fa6b57b6ed Merge remote-tracking branch 'origin/dev' into pr-542 2026-08-06 02:22:42 +03:00
Fringg
02b3ffd4a0 Merge pull request #539 from off-art/feat/manual-deeplink-login 2026-08-06 02:16:11 +03:00
Fringg
c1088325aa fix(auth): вернуть виджет после «Назад» и добить локали до четырёх
Дополнения к PR #539 при мерже.

Кнопка «Назад к входу через виджет» оставляла пустое место. Пока открыт
deep-link-экран, контейнер виджета размонтирован ранним return'ом, а эффект,
который вставляет скрипт Telegram, от этого не перезапускается: его
зависимости не меняются. Единственная зависимость, способная дрогнуть, —
handleScriptFailed через scriptLoaded, но на legacy-пути scriptLoaded не
выставляется никогда (только в OIDC-ветке). Так что при возврате контейнер
монтировался обратно уже пустым, и войти через виджет было нельзя до
перезагрузки страницы. Добавлен showDeepLinkUI в зависимости и ранний выход:
на входе в deep-link отрабатывает cleanup, на выходе скрипт вставляется заново.

Локали были только en и ru, а в кабинете их четыре. fallbackLng — 'ru',
поэтому персидские и китайские пользователи увидели бы русский текст на самом
первом экране. Именно этот класс регрессии описан в шапке locales.test.ts, и
поймать его тест не может: он сравнивает только en и ru. Добавлены fa и zh;
в zh термин «виджет» приведён к тому же 小部件, что уже используется в
telegramWidgetBlocked.
2026-08-06 02:16:02 +03:00
Fringg
76e10cc06c Merge remote-tracking branch 'origin/dev' into pr-539 2026-08-06 02:13:07 +03:00
Fringg
fdaf03feff Merge pull request #534 from BlackRaincoat/feat/sort-users-by-subscription-expiry 2026-08-06 02:11:20 +03:00
Fringg
2d6f8d9754 fix(admin): дополнить тип sort_by и китайскую строку сортировки
Дополнения к PR #534 при мерже.

Юнион sort_by в adminUsers.ts не знал про subscription_end_date. Ошибки
компиляции нет только потому, что вызывающий код собирает params как
Record<string, unknown> и приводит его каст-выражением, то есть юнион там
вообще не проверяется. Но это единственная запись о том, какие значения
принимает эндпоинт, и она разъехалась с реальностью.

Китайская строка «按到期» ставит после 按 глагол, тогда как все соседние
пункты — 按日期, 按余额, 按活跃度, 按消费 — существительные. Сам словарь
кабинета для этого поля использует 到期时间 (subscription.expiresAt,
admin.users.detail.expiresAt, promo.expiresIn), поэтому привёл к той же форме.

Русскую строку не трогал: «по истечению» здесь дательный падеж критерия, как
у соседей («по дате», «по балансу»), а не предложный «по истечении» из
временного оборота.
2026-08-06 02:11:14 +03:00
Fringg
b3a3dc4ff4 Merge remote-tracking branch 'origin/dev' into pr-534 2026-08-06 02:04:02 +03:00
Case211
06c7d13883 feat(dashboard): плитка «Подключить устройство» на главной
Подписку может выдать бонус рекламной кампании — она создаётся
автоматически, и человек попадает на главную с уже готовым доступом.
Плитка подключения жила только в карточке подписки на отдельной
странице, так что на главной ему нечем было воспользоваться: подписка
есть, а как подключить устройство — не видно.

Плитка вынесена из SubscriptionCardActive в отдельный компонент и
переиспользована обоими экранами, второй копии разметки не появилось.
На главной показывается, пока подписка одна: при нескольких выбор
устройства неоднозначен, и уместнее уходить в конкретную подписку.
2026-08-05 00:30:40 +05:00
Case211
f6a64bfc1e feat(promocodes): трафик в наборе бонусов промокода
Набор собирался из баланса, дней и промогруппы. Трафик добавляется
четвёртой галочкой: подписка у пользователя уже есть, и гигабайты
начисляются к ней без смены тарифа.

Включённый трафик переводит код в тип «набор бонусов» — на бэкенде
трафик живёт только там. Валидация требует положительный объём, как у
суммы и дней; при редактировании галочка поднимается сама, если у кода
уже задан трафик.
2026-08-05 00:02:29 +05:00
Case211
eafc563128 feat(users): кнопка удаления подписки в карточке пользователя
В мультитарифном режиме у пользователя несколько подписок, и убрать
лишнюю — например, отработавший триал — можно было только через экран
«Массовые действия». Во вкладке «Подписка» карточки подписки видны и
открываются, но удалить выбранную нечем.

Кнопка живёт в детальном виде подписки и требует подтверждения тем же
inline-механизмом, что и отмена автоплатежа; ключ подтверждения включает
id подписки, чтобы взведённое согласие не пережило переключение на
соседнюю. Активная платная подписка на сервере защищена от случайного
удаления, поэтому для неё запрос идёт с явным force — намерение админ
уже подтвердил.
2026-08-04 23:38:20 +05:00
github-actions[bot]
e50b10a433 Merge remote-tracking branch 'upstream/main' 2026-08-04 08:28:07 +00:00
Egor
596b39b765 Merge pull request #540 from BEDOLAGA-DEV/main
up
2026-08-03 23:04:25 +03:00
Artem (OpenIN)
75424dba47 refactor(auth): consolidate three Telegram entry points into two
- Drop the passive '@bot_username' link (opens the bot chat with no
  auth purpose) for the common case — it's now redundant with the new
  'Login via bot' button, which offers the same open-bot action plus
  QR and actual authentication.
- Keep the referral deep link (bot start=<referral_code>) only when a
  referralCode prop is present — that's a distinct registration flow
  for not-yet-registered users, not a login method.
- Add an 'or' divider between the widget and the manual deep-link
  button so the two remaining options read as equal alternatives
  rather than a stack of similar-looking Telegram links.
- Shorten the deep-link button label to 'Login via bot' — the
  no-phone-number framing is already implied and shown once the flow
  starts.

No changes to the OIDC/widget button logic itself.
2026-08-03 21:37:31 +03:00
Artem (OpenIN)
d03b9bdad7 feat(auth): allow manual opt-in for deep-link Telegram login
Currently the deep-link auth flow (t.me/{bot}?start=webauth_{token}) is
only triggered automatically as a fallback when the Telegram widget
script (oauth.telegram.org or telegram.org/js/telegram-widget.js) fails
to load. Users on unaffected networks have no way to choose this login
method even when they'd prefer confirming in the bot over typing a
phone number into the widget popup.

This adds a small 'Login via bot' link next to the existing widget,
reusing the exact same startDeepLinkAuth/poll logic already used by
the automatic fallback. No changes to the fallback behavior itself.
2026-08-03 21:37:31 +03:00
Egor
b866bebeeb Merge pull request #538 from BEDOLAGA-DEV/release-please--branches--main--components--cabinet-frontend
chore(main): release 1.65.0
2026-08-03 19:22:47 +03:00
github-actions[bot]
4c8e17a5a4 chore(main): release 1.65.0 2026-08-03 16:22:13 +00:00
Egor
c718c4ca34 Merge pull request #537 from BEDOLAGA-DEV/feat/remnawave-3.0.0
Feat/remnawave 3.0.0
2026-08-03 19:21:32 +03:00
Fringg
e840ed2b60 feat(admin): панельная идентичность под Remnawave 3.0.0
Remnawave 3.0.0 удалил `uuid` из модели пользователя — панельный аккаунт
адресуется числовым `id`, и бот переименовал соответствующие поля своего
API. Без этих правок вкладка Sync показывала бы «не привязан» у ВСЕХ
пользователей: оба операнда стали бы undefined, причём без ошибки TS —
интерфейсы продолжали бы декларировать несуществующие поля.

Типы: `UserDetailResponse.remnawave_uuid` → `remnawave_id: number | null`,
`PanelSyncStatusResponse.remnawave_uuid` → `remnawave_id`,
`SyncToPanelResponse.panel_uuid` → `panel_user_id`,
`PanelUserInfo.uuid` → `id: number` (бэкенд отдаёт его обязательным).

В SyncTab используется `??`, а не `||`: идентификатор числовой, и `0`
нужно отличать от отсутствия значения. Ярлык — «Remnawave ID».

Локаль: ключ настройки бота переименован в TRAFFIC_EXCLUDED_USER_IDS,
подпись обновлена — значения теперь числовые id, а не UUID.
2026-08-02 19:38:11 +03:00
Dmitry Lunin
89a9f9f70c feat: add sort by subscription expiry to user list 2026-07-31 01:29:16 +03:00
Fringg
653d683835 fix(settings): вернуть в админку категории, которых не было в дереве
Дерево разделов настроек — захардкоженный список категорий, а на бэкенде категория
выводится из имени ключа. Любая настройка, создавшая новую категорию, просто
исчезала из админки: настройки были в API, но ни один раздел их не показывал.

Так пропали 33 категории из 102, в том числе:
- CABINET целиком (13 настроек, включая CABINET_ENABLED, CABINET_URL,
  CABINET_REQUIRE_LEGAL_CONSENT и CABINET_LEGAL_CONSENT_PRECHECKED);
- INFO_PAGES — режимы показа оферты, политики, правил и FAQ;
- MENU — кнопка меню Telegram с открытием кабинета.

Эти три разложены по дереву явно. Остальное собирает новый пункт «Прочие
настройки» — по образцу админки бота, где такой раздел уже есть. Это и есть
главная часть фикса: теперь новая категория на бэкенде не приводит к молчаливому
исчезновению настроек, максимум — к неудачному разделу.

Локали ru/en/zh/fa, +5 тестов на само дерево.
2026-07-30 01:58:05 +03:00
57 changed files with 3708 additions and 539 deletions

View File

@@ -1,5 +1,50 @@
# Changelog
## [1.66.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.65.0...v1.66.0) (2026-08-20)
### Features
* **auth:** allow manual opt-in for deep-link Telegram login ([d03b9bd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d03b9bdad785ac7710f30527f230eeb793e1089b))
* **dashboard:** плитка «Подключить устройство» на главной ([06c7d13](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/06c7d1388308de5fc1adad0cd2697d3c03c41c49))
* **dashboard:** подключение устройства прикреплено к карточке подписки ([28e25a9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/28e25a92922501ed609623776e987fcd96f71032))
* **landing:** автозаполнение поля контакта из параметра URL (?contact=...) ([8d0c33b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8d0c33bb08b8f28b19367d1ea612a8455fba0ff1))
* **landing:** отправлять слаг рекламной кампании при гостевой покупке ([cfc2ab2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cfc2ab2f7fe641570a818d0ca75c9cd9dddbf2b7))
* **landing:** отправлять слаг рекламной кампании при гостевой покупке ([d4379fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d4379fce25af69e866e9232a3cd036652e7eae17))
* **promocodes:** трафик в наборе бонусов промокода ([f6a64bf](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6a64bfc1eb885cde31bcbab1f1474b1d693e718))
* **remnawave:** GeoCheck ноды в админке — запуск проверки и просмотр отчёта ([b4e1de6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b4e1de6152cfc85418c62e4f840180411989a2a7))
* **users:** кнопка удаления подписки в карточке пользователя ([eafc563](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eafc563128b68f49f0a1f03a30c899b527ce4734))
### Bug Fixes
* **admin:** дополнить тип sort_by и китайскую строку сортировки ([2d6f8d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d6f8d97546f7ca69540924aa0637f720500bef3))
* **auth:** вернуть виджет после «Назад» и добить локали до четырёх ([c108832](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1088325aabaf43de6f60653ff650c3cfb1beb2f))
* **auth:** читать initData из моста Telegram, а не только из кэша SDK ([f81af40](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f81af401f7f6ca1a28806cbf237a33da7449d893))
* **dashboard:** счётчик устройств в плитке на главной ([39b9b8e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/39b9b8e1d2690c894ce08b0ce16d4780ecffee09))
* **geocheck:** сквозные клики через портал и safe-зоны в полноэкранном режиме ([debe26a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/debe26ae359545fead38bf087b069e9defecec26))
* **geocheck:** скелетон до отрисовки отчёта и сброс зума на новом отчёте ([5c3031f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5c3031f5f52e4a7bbe101441a97f5e47dd1eff3f))
* **geocheck:** убрать полноэкранный режим в Telegram Mini App ([5db1ffc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5db1ffccf6a6659c06a1fbe5b2b703abd0c609bd))
* **geocheck:** убрать скачивание отчёта в Telegram Mini App ([a451a12](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a451a129fe0b211369f336f09f8c8e1abd105173))
* **landing:** не оставлять контакт из ссылки в адресной строке ([acbffa4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/acbffa4e17e4549a1068f286e3009b38d30ae6be))
* **promocodes:** чекбоксы набора по значениям, трафик в списке ([29c9ed1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/29c9ed1a40bad95b187fabd81098a795d4c096da))
* **support:** показывать пользователю отказ бэка при создании тикета ([d889289](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d88928912acaaf9eeee52235ee4e2aace5e245e7))
* **tickets:** длинный текст в сообщении уезжал за пределы карточки ([eede5ff](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eede5ffdfdde9ece9956817c646ade36a9b2ccf6))
* **tickets:** длинный текст в сообщении уезжал за пределы карточки ([2ba6fc0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2ba6fc0f6f743b8648c0d1e708cd123a465ee77f))
* **users:** показывать причину отказа при удалении подписки ([415a76f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/415a76f7cff4e0e06edb15b91a24467f0465e2e9))
## [1.65.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.64.0...v1.65.0) (2026-08-03)
### Features
* **admin:** панельная идентичность под Remnawave 3.0.0 ([e840ed2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e840ed2b60f5e525cb290ce5bddc70ef6dfe8a66))
### Bug Fixes
* **settings:** вернуть в админку категории, которых не было в дереве ([653d683](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/653d683835e2c10f7690f1f71df3552c3ba9ab2f))
## [1.64.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.63.0...v1.64.0) (2026-07-29)

19
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cabinet-frontend",
"version": "1.64.0",
"version": "1.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabinet-frontend",
"version": "1.64.0",
"version": "1.66.0",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -59,6 +59,7 @@
"react-icons": "^5.6.0",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"react-zoom-pan-pinch": "^4.0.4",
"recharts": "^3.7.0",
"sigma": "^3.0.2",
"simplex-noise": "^4.0.3",
@@ -6211,6 +6212,20 @@
"react-dom": ">=19.0.0"
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.4.tgz",
"integrity": "sha512-P0D7lfNHyJCNuUozoVdt0WNWcQ34ZbbD71B8pb+UtF7Th8MKBnxYiPApDfPZZmr+Gk0l0WmGTXL8R36JirHcQQ==",
"license": "MIT",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "cabinet-frontend",
"private": true,
"version": "1.64.0",
"version": "1.66.0",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module",
@@ -71,6 +71,7 @@
"react-icons": "^5.6.0",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"react-zoom-pan-pinch": "^4.0.4",
"recharts": "^3.7.0",
"sigma": "^3.0.2",
"simplex-noise": "^4.0.3",

View File

@@ -112,6 +112,23 @@ export interface SystemStatsResponse {
}
// Nodes
export type NodeIpStatus =
| 'INBOUND'
| 'OUTBOUND'
| 'MANAGEMENT'
| 'TRANSIT'
| 'MONITORING'
| 'RESERVE'
| 'BLOCKED'
| 'FLAGGED'
| 'DEPRECATED'
| 'UNKNOWN';
export interface NodeIpAddress {
ip: string;
status: NodeIpStatus;
}
export interface NodeInfo {
uuid: string;
name: string;
@@ -165,6 +182,8 @@ export interface NodeInfo {
};
} | null;
active_plugin_uuid?: string;
/** Адреса узла из панели — варианты исходного адреса для GeoCheck. */
ips?: NodeIpAddress[];
config_profile?: {
active_config_profile_uuid: string | null;
active_inbounds: Array<{
@@ -206,6 +225,40 @@ export interface NodeActionResponse {
is_disabled?: boolean;
}
// GeoCheck (Remnawave 3.3.0)
/** С какого маршрута гнать проверку: пусто — маршрут узла по умолчанию. */
export interface GeoCheckRequest {
ip?: string;
interface?: string;
}
export interface GeoCheckStartResponse {
job_id: string;
}
/** SVG-отчёт в base64, готовый для data: URL. */
export interface GeoCheckImage {
format: string;
media_type: string;
encoding: string;
data: string;
}
export interface GeoCheckResult {
success: boolean;
node_uuid?: string | null;
image?: GeoCheckImage | null;
raw_report?: Record<string, unknown> | null;
message?: string | null;
}
export interface GeoCheckJobResponse {
job_id: string;
is_completed: boolean;
is_failed: boolean;
result?: GeoCheckResult | null;
}
// Realtime Traffic
export interface InboundTraffic {
tag: string;
@@ -403,6 +456,21 @@ export const adminRemnawaveApi = {
return response.data;
},
/** Ставит GeoCheck ноды в очередь; результат забирается по job_id. */
startNodeGeoCheck: async (
uuid: string,
body: GeoCheckRequest = {},
): Promise<GeoCheckStartResponse> => {
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/geocheck`, body);
return response.data;
},
/** Статус задачи GeoCheck — нода отвечает до минуты. */
getGeoCheckJob: async (jobId: string): Promise<GeoCheckJobResponse> => {
const response = await apiClient.get(`/cabinet/admin/remnawave/geocheck/${jobId}`);
return response.data;
},
restartAllNodes: async (): Promise<NodeActionResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
return response.data;

View File

@@ -142,7 +142,7 @@ export interface UserDetailResponse {
promo_offer_discount_source: string | null;
promo_offer_discount_expires_at: string | null;
recent_transactions: UserTransactionItem[];
remnawave_uuid: string | null;
remnawave_id: number | null;
}
export interface UserPanelInfo {
@@ -271,7 +271,7 @@ export interface UserAvailableTariffsResponse {
// Sync types
export interface PanelUserInfo {
uuid: string | null;
id: number;
short_uuid: string | null;
username: string | null;
status: string | null;
@@ -295,7 +295,7 @@ export interface SyncToPanelResponse {
success: boolean;
message: string;
action: string;
panel_uuid: string | null;
panel_user_id: number | null;
changes: Record<string, unknown>;
errors: string[];
}
@@ -303,7 +303,7 @@ export interface SyncToPanelResponse {
export interface PanelSyncStatusResponse {
user_id: number;
telegram_id: number;
remnawave_uuid: string | null;
remnawave_id: number | null;
subscription_id: number | null;
subscription_tariff_name: string | null;
last_sync: string | null;
@@ -456,7 +456,8 @@ export const adminUsersApi = {
| 'traffic'
| 'last_activity'
| 'total_spent'
| 'purchase_count';
| 'purchase_count'
| 'subscription_end_date';
} = {},
): Promise<UsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/users', { params });
@@ -518,6 +519,19 @@ export const adminUsersApi = {
return response.data;
},
// Delete one of the user's subscriptions (multi-tariff: trials pile up)
deleteSubscription: async (
userId: number,
subId: number,
force = false,
): Promise<{ status: string }> => {
const response = await apiClient.delete(
`/cabinet/admin/users/${userId}/subscriptions/${subId}`,
{ params: force ? { force: true } : undefined },
);
return response.data;
},
// Update status
updateStatus: async (
userId: number,

View File

@@ -1,5 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
import {
tokenStorage,
isTokenExpired,
@@ -41,13 +41,11 @@ function ensureCsrfToken(): string {
const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null;
try {
const raw = retrieveRawInitData();
if (raw) {
tokenStorage.setTelegramInitData(raw);
return raw;
}
} catch {}
const raw = readTelegramInitData();
if (raw) {
tokenStorage.setTelegramInitData(raw);
return raw;
}
return tokenStorage.getTelegramInitData();
};

View File

@@ -114,6 +114,10 @@ export interface PurchaseRequest {
yandex_cid?: string;
referrer?: string;
subid?: string;
// Слаг рекламной кампании: без него покупка гостем не попадает в статистику
// кампании и не даёт её бонус — auth-флоу, который привязывает кампанию
// обычно, на этом пути не срабатывает.
campaign_slug?: string;
}
export interface PurchaseResponse {

View File

@@ -17,6 +17,8 @@ export interface PromoCode {
balance_bonus_kopeks: number;
balance_bonus_rubles: number;
subscription_days: number;
/** Гигабайты к подписке — третья составляющая набора бонусов. */
traffic_gb: number;
max_uses: number;
current_uses: number;
uses_left: number;
@@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest {
type: PromoCodeType;
balance_bonus_kopeks?: number;
subscription_days?: number;
traffic_gb?: number;
max_uses?: number;
valid_from?: string;
valid_until?: string | null;
@@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest {
type?: PromoCodeType;
balance_bonus_kopeks?: number;
subscription_days?: number;
traffic_gb?: number;
max_uses?: number;
valid_from?: string;
valid_until?: string | null;

View File

@@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const [oidcError, setOidcError] = useState('');
const [scriptLoaded, setScriptLoaded] = useState(false);
const [scriptFailed, setScriptFailed] = useState(false);
// Lets the user opt into deep-link auth manually, without waiting for the
// Telegram widget script to fail. See #<issue-number>.
const [manualDeepLink, setManualDeepLink] = useState(false);
const showDeepLinkUI = scriptFailed || manualDeepLink;
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
// Deep link auth state
@@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
// showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер
// виджета размонтирован, а при возврате «Назад к виджету» сам по себе
// эффект не перезапустится — на legacy-пути scriptLoaded не меняется
// никогда, поэтому ни одна из остальных зависимостей не дрогнет, и
// пользователь получил бы пустое место вместо виджета.
if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
const container = containerRef.current;
while (container.firstChild) {
@@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
container.removeChild(container.firstChild);
}
};
}, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]);
}, [
showDeepLinkUI,
isOIDC,
botUsername,
widgetConfig,
loginWithTelegramWidget,
navigate,
handleScriptFailed,
]);
// Deep link auth: request token and start polling with recursive setTimeout
const startDeepLinkAuth = useCallback(async () => {
@@ -336,9 +353,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
}
}, [botUsername, loginWithDeepLink, navigate, t]);
// Auto-start deep link auth when script fails (with cancellation for Strict Mode)
// Auto-start deep link auth when script fails OR the user opts in manually
// (with cancellation for Strict Mode)
useEffect(() => {
if (scriptFailed && !deepLinkToken && !deepLinkPolling) {
if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) {
let cancelled = false;
const start = async () => {
if (!cancelled) await startDeepLinkAuth();
@@ -348,7 +366,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
cancelled = true;
};
}
}, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
}, [showDeepLinkUI, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
// Resume polling immediately when user returns to the page (e.g. after confirming in Telegram)
// Browsers throttle setTimeout in background tabs, so polling may have stalled.
@@ -431,8 +449,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
);
}
// Deep link fallback UI
if (scriptFailed) {
// Deep link UI — shown either as an automatic fallback (widget script
// failed to load) or because the user explicitly chose this method.
if (showDeepLinkUI) {
const resolvedBotUsername = deepLinkBotUsername || botUsername;
const deepLinkUrl = deepLinkToken
? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}`
@@ -443,7 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
<div className="flex flex-col items-center space-y-5">
{/* Info message */}
<p className="max-w-xs text-center text-xs text-dark-400">
{t('auth.telegramWidgetBlocked')}
{t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}
</p>
{deepLinkToken && deepLinkUrl ? (
@@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{t('common.loading')}
</div>
)}
{/* Only offer a way back if the widget actually works — if the
script failed there is nothing to go back to. */}
{!scriptFailed && (
<button
type="button"
onClick={() => {
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
if (expireTimeoutRef.current) clearTimeout(expireTimeoutRef.current);
pollTimeoutRef.current = null;
expireTimeoutRef.current = null;
setDeepLinkToken(null);
setDeepLinkPolling(false);
setDeepLinkError('');
setManualDeepLink(false);
}}
className="text-xs text-dark-400 underline decoration-dotted transition-colors hover:text-dark-300"
>
{t('auth.backToWidget')}
</button>
)}
</div>
);
}
@@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
<div ref={containerRef} className="flex justify-center" />
)}
<div className="text-center">
<p className="mb-2 text-xs text-dark-400">{t('auth.orOpenInApp')}</p>
{/* Referral deep link — only relevant for not-yet-registered users who
arrived via a referral link; the bot itself handles registering
them with the code attached. Hidden otherwise to avoid a third,
visually-identical "Telegram" entry point next to the two auth
methods below. */}
{referralCode && (
<a
href={
referralCode
? `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`
: `https://t.me/${botUsername}`
}
href={`https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`}
target="_blank"
rel="noopener noreferrer"
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
className="text-telegram-blue inline-flex items-center text-xs hover:underline"
>
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
@{botUsername}
{t('auth.orOpenInApp')}&nbsp;@{botUsername}
</a>
)}
<div className="flex w-full max-w-xs items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<span className="text-[11px] text-dark-500">{t('common.or')}</span>
<div className="h-px flex-1 bg-dark-700" />
</div>
{/* Manual opt-in: same deep-link flow used as the anti-block fallback,
offered here as an explicit equal alternative to the widget for
users who'd rather confirm in the bot than type a phone number. */}
<button
type="button"
onClick={() => setManualDeepLink(true)}
className="inline-flex items-center gap-2 rounded-lg border border-dark-700 bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-800"
>
<svg className="h-5 w-5 text-telegram-blue" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
{t('auth.loginWithBot')}
</button>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { OTHER_CATEGORIES, SETTINGS_TREE, getMappedCategoryKeys } from './constants';
// Категории настроек на бэкенде выводятся из имени ключа, поэтому каждая новая
// настройка может создать категорию, которой в дереве нет. Раньше такие настройки
// просто не отображались: так исчезла вся категория CABINET (13 настроек, включая
// CABINET_ENABLED и CABINET_URL) и правовые документы из INFO_PAGES.
describe('SETTINGS_TREE', () => {
it('содержит пункт-сборник для категорий без своего места', () => {
const children = SETTINGS_TREE.groups.flatMap((group) => group.children);
const catchAll = children.filter((child) => child.categories.includes(OTHER_CATEGORIES));
expect(catchAll).toHaveLength(1);
expect(catchAll[0].id).toBe('sys_other');
});
it('не считает маркер сборника обычной категорией', () => {
expect(getMappedCategoryKeys().has(OTHER_CATEGORIES)).toBe(false);
});
it('раскладывает по дереву категории, которые раньше терялись', () => {
const mapped = getMappedCategoryKeys();
for (const category of ['CABINET', 'INFO_PAGES', 'MENU']) {
expect(mapped.has(category)).toBe(true);
}
});
it('незнакомая категория попадает в сборник, а не пропадает', () => {
const mapped = getMappedCategoryKeys();
// Так выглядит любая категория, появившаяся на бэкенде после этого релиза.
expect(mapped.has('SOME_FUTURE_CATEGORY')).toBe(false);
});
it('не содержит категорию дважды', () => {
const all = SETTINGS_TREE.groups
.flatMap((group) => group.children)
.flatMap((child) => child.categories)
.filter((category) => category !== OTHER_CATEGORIES);
expect(new Set(all).size).toBe(all.length);
});
});

View File

@@ -1,4 +1,4 @@
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
import { type ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
// Tree sidebar types
export interface TreeSubItem {
@@ -23,6 +23,9 @@ export interface SettingsTreeConfig {
groups: TreeGroup[];
}
// Маркер пункта-сборника: подставляется вместо списка категорий.
export const OTHER_CATEGORIES = '*';
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
export const SETTINGS_TREE: SettingsTreeConfig = {
specialItems: [
@@ -91,6 +94,8 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
{ id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
{ id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
{ id: 'iface_skip', categories: ['SKIP'] },
{ id: 'iface_info', categories: ['INFO_PAGES'] },
{ id: 'iface_menu', categories: ['MENU'] },
{ id: 'iface_additional', categories: ['ADDITIONAL'] },
],
},
@@ -131,6 +136,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
{ id: 'sys_core', categories: ['CORE', 'DEBUG'] },
{ id: 'sys_remnawave', categories: ['REMNAWAVE'] },
{ id: 'sys_webapi', categories: ['WEB_API', 'EXTERNAL_ADMIN'] },
{ id: 'sys_cabinet', categories: ['CABINET'] },
{ id: 'sys_webhook', categories: ['WEBHOOK'] },
{ id: 'sys_server', categories: ['SERVER_STATUS'] },
{ id: 'sys_monitoring', categories: ['MONITORING'] },
@@ -138,11 +144,29 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
{ id: 'sys_backup', categories: ['BACKUP'] },
{ id: 'sys_version', categories: ['VERSION'] },
{ id: 'sys_logging', categories: ['LOG'] },
// Категории на бэкенде выводятся из имени ключа, поэтому каждая новая
// настройка может создать категорию, которой в дереве нет — и тогда она
// просто исчезала из админки (так пропала вся категория CABINET).
// Этот пункт собирает всё неразложенное, как «Прочее» в админке бота.
{ id: 'sys_other', categories: [OTHER_CATEGORIES] },
],
},
],
};
// Категории, у которых есть своё место в дереве (маркер сборника не считается).
export function getMappedCategoryKeys(): Set<string> {
const mapped = new Set<string>();
for (const group of SETTINGS_TREE.groups) {
for (const child of group.children) {
for (const category of child.categories) {
if (category !== OTHER_CATEGORIES) mapped.add(category);
}
}
}
return mapped;
}
// Helper: find which group and sub-item a backend category key belongs to
export function findTreeLocation(
categoryKey: string,

View File

@@ -0,0 +1,167 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
/**
* Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается.
* Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком
* экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно
* такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом.
*/
const READABLE_WIDTH = 760;
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
/** Ширины строк-заглушек, повторяющие ритм отчёта: заголовок, строки, пробел. */
const SKELETON_ROWS = [42, 88, 80, 84, 30, 70, 76, 82, 64, 28, 86, 74, 80, 68];
const SCALE_MIN = 1;
const SCALE_MAX = 8;
interface GeoCheckImageViewerProps {
src: string;
alt: string;
/** Смена режима меняет доступную ширину — просмотрщик пересобирается. */
fullscreen: boolean;
}
/**
* Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап.
*
* Зум свой, а не браузерный: в `index.html` приложения стоит
* `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в
* Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши,
* пальца и трекпада — так же это сделано в самой панели Remnawave.
*
* Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка
* над отчётом превращалась бы в зум.
*/
export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) {
const { t } = useTranslation();
const hostRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
// Отчёт весит сотни килобайт вместе со встроенным шрифтом, и между приходом
// данных и первой отрисовкой есть заметная пауза. До неё показываем
// скелетон, а не пустую тёмную коробку с кнопками зума.
//
// Готовность хранится как «какая картинка отрисована», а не флагом: тогда
// новый отчёт автоматически считается незагруженным, без сбрасывающего
// эффекта.
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const loaded = loadedSrc === src;
// Подпись отчёта для ключа. Длины и хвоста base64 недостаточно: отчёты
// одного узла кончаются одинаково и совпали бы по такой подписи, а зум
// тогда не сбрасывается. Полная строка в ключе — сотни килобайт на каждый
// рендер, поэтому считаем хеш один раз на отчёт.
const srcId = useMemo(() => {
let hash = 0;
for (let i = 0; i < src.length; i += 1) {
hash = (hash * 31 + src.charCodeAt(i)) | 0;
}
return `${src.length}:${hash}`;
}, [src]);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(host);
setWidth(host.clientWidth);
return () => observer.disconnect();
}, []);
// Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на
// узком — поднимаем до читаемого и позволяем панорамировать.
const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1;
const controlButton =
'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95';
return (
<div ref={hostRef} className="relative h-full">
{!loaded && (
<div className="absolute inset-0 z-10 space-y-2 p-4" aria-busy="true">
{SKELETON_ROWS.map((w) => (
<div
key={w}
className="h-3 animate-pulse rounded bg-dark-700/50"
style={{ width: `${w}%` }}
/>
))}
</div>
)}
{width > 0 && (
<TransformWrapper
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
// ничего не сбрасывает.
// `src` в ключе: перезапуск проверки даёт новый отчёт, и он должен
// открыться в исходном масштабе, а не унаследовать зум и сдвиг от
// предыдущего.
key={`${srcId}-${fullscreen}-${Math.round(initialScale * 10)}`}
centerOnInit={false}
disablePadding
doubleClick={{ mode: 'toggle' }}
initialScale={initialScale}
maxScale={SCALE_MAX}
minScale={SCALE_MIN}
trackPadPanning={{ disabled: false }}
wheel={{ wheelDisabled: true }}
>
{({ resetTransform, zoomIn, zoomOut }) => (
<>
<TransformComponent
contentStyle={{ width: '100%' }}
// touch-action: none — все жесты обрабатывает библиотека,
// иначе браузер перехватит их под прокрутку.
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing"
>
<img
alt={alt}
className="block w-full select-none"
draggable={false}
src={src}
onLoad={() => setLoadedSrc(src)}
onError={() => setLoadedSrc(src)}
/>
</TransformComponent>
{loaded && (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-xl border border-dark-100/10 bg-dark-950/70 p-1 shadow-lg backdrop-blur-md">
<button
type="button"
onClick={() => zoomOut()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
>
<MinusIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => zoomIn()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
>
<PlusIcon className="h-4 w-4" />
</button>
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
<button
type="button"
onClick={() => resetTransform()}
className={controlButton}
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
>
<ResetIcon className="h-4 w-4" />
</button>
</div>
)}
</>
)}
</TransformWrapper>
)}
</div>
);
}

View File

@@ -0,0 +1,234 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { NodeInfo } from '@/api/adminRemnawave';
import { GeoCheckIcon, PlayIcon, WarningIcon, XCloseIcon } from '@/components/icons';
import { Spinner } from '@/components/ui/Spinner';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { useIsTelegram } from '@/platform/hooks/usePlatform';
import { cn } from '@/lib/utils';
import { GeoCheckReport } from './GeoCheckReport';
import { buildGeoCheckRequest, isRouteReady, type GeoCheckRouteMode } from './geoCheckRoute';
import { GeoCheckSetup } from './GeoCheckSetup';
import { useGeoCheckJob } from './useGeoCheckJob';
interface GeoCheckModalProps {
node: NodeInfo;
onClose: () => void;
}
/**
* GeoCheck ноды: выбор маршрута → ожидание → отчёт.
*
* Полноэкранный просмотр — режим самой модалки, а не отдельный оверлей:
* оверлей поверх портала выпал бы из ловушки фокуса, и его кнопка закрытия
* стала бы недоступна с клавиатуры.
*/
export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<GeoCheckRouteMode>('default');
const [value, setValue] = useState('');
const [fullscreenRequested, setFullscreenRequested] = useState(false);
// В Mini App окно Telegram и так занимает экран целиком: отдельный
// полноэкранный режим там только снимал отступы и залезал под хром.
// Тот же признак закрывает и скачивание: в webview Telegram оно уводит
// из приложения вместо сохранения файла.
const isTelegram = useIsTelegram();
const canFullscreen = !isTelegram;
const canDownload = !isTelegram;
const fullscreen = canFullscreen && fullscreenRequested;
const job = useGeoCheckJob(node.uuid);
const isRunning = job.phase === 'running';
// Пока идёт проверка, закрывать по Escape нельзя: задача уже поставлена
// в панель, и молча потерять её результат — хуже, чем подождать.
const dialogRef = useFocusTrap<HTMLDivElement>(true, {
onEscape: isRunning ? undefined : fullscreen ? () => setFullscreenRequested(false) : onClose,
});
useEffect(() => {
if (job.phase !== 'done') setFullscreenRequested(false);
}, [job.phase]);
const canStart = isRouteReady(mode, value);
const handleStart = () => {
if (!canStart) return;
job.start(buildGeoCheckRequest(mode, value));
};
const handleBackdropClick = () => {
if (!isRunning) onClose();
};
const errorText =
job.error?.kind === 'timeout'
? t(
'admin.remnawave.geoCheck.error.timeout',
'The node did not answer in time. Try running the check again.',
)
: (job.error?.message ??
t('admin.remnawave.geoCheck.error.generic', 'The check could not be completed.'));
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
// Safe-зоны обязательны и в полноэкранном режиме: в Mini App сверху
// висит шапка Telegram, снизу — home indicator, и обнулённые отступы
// прятали под ними панель инструментов и кнопки зума.
style={{
paddingTop: fullscreen ? 'env(safe-area-inset-top)' : 'max(1rem, env(safe-area-inset-top))',
paddingBottom: fullscreen
? 'env(safe-area-inset-bottom)'
: 'max(1rem, env(safe-area-inset-bottom))',
paddingLeft: fullscreen
? 'env(safe-area-inset-left)'
: 'max(1rem, env(safe-area-inset-left))',
paddingRight: fullscreen
? 'env(safe-area-inset-right)'
: 'max(1rem, env(safe-area-inset-right))',
}}
>
<div
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
onClick={handleBackdropClick}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="geocheck-modal-title"
tabIndex={-1}
className={cn(
'relative flex w-full flex-col border border-dark-700 bg-dark-900 shadow-2xl',
// max-h-full, а не 100dvh: отступы safe-зон уже урезали контейнер,
// и абсолютная высота вылезала бы за них.
fullscreen
? 'h-full max-h-full max-w-none rounded-none p-3'
: 'max-h-full overflow-y-auto rounded-2xl p-5',
// Отчёт заметно шире формы запуска — под него модалка расширяется.
!fullscreen && (job.phase === 'done' ? 'max-w-4xl' : 'max-w-2xl'),
)}
>
{/* В полноэкранном режиме шапка ужимается в одну строку: место по
вертикали — ровно то, ради чего его и включают. */}
<div
className={cn('flex items-center justify-between gap-3', fullscreen ? 'mb-2' : 'mb-4')}
>
<div className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
'flex shrink-0 items-center justify-center rounded-xl bg-accent-500/15 text-accent-400',
fullscreen ? 'h-7 w-7' : 'h-9 w-9',
)}
>
<GeoCheckIcon className={fullscreen ? 'h-4 w-4' : 'h-5 w-5'} />
</span>
<div className="min-w-0">
<h3
id="geocheck-modal-title"
className={cn(
'font-semibold text-dark-100',
fullscreen ? 'truncate text-sm' : 'text-lg',
)}
>
{fullscreen ? node.name : t('admin.remnawave.geoCheck.title', 'GeoCheck')}
</h3>
{!fullscreen && <p className="truncate text-xs text-dark-400">{node.name}</p>}
</div>
</div>
{!isRunning && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close', 'Close')}
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
<XCloseIcon />
</button>
)}
</div>
{job.phase === 'idle' && (
<>
<GeoCheckSetup
node={node}
mode={mode}
value={value}
onModeChange={(next) => {
setMode(next);
setValue('');
}}
onValueChange={setValue}
/>
<div className="mt-5 flex items-center justify-end gap-2">
<button type="button" onClick={onClose} className="btn-ghost">
{t('common.close', 'Close')}
</button>
<button
type="button"
onClick={handleStart}
disabled={!canStart}
className="btn-primary"
>
<PlayIcon className="h-4 w-4" />
{t('admin.remnawave.geoCheck.start', 'Run check')}
</button>
</div>
</>
)}
{isRunning && (
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<Spinner className="h-10 w-10" />
<p className="text-sm font-medium text-dark-100">
{t('admin.remnawave.geoCheck.running', 'Running the geo check')}
</p>
<p className="max-w-sm text-xs text-dark-400">
{t(
'admin.remnawave.geoCheck.runningHint',
'The node is testing its connection — this usually takes up to a minute.',
)}
</p>
</div>
)}
{job.phase === 'error' && (
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-error-500/15 text-error-400">
<WarningIcon className="h-6 w-6" />
</span>
<p className="text-sm font-medium text-dark-100">
{t('admin.remnawave.geoCheck.error.title', 'Check failed')}
</p>
<p className="max-w-sm break-words text-xs text-dark-400">{errorText}</p>
<div className="mt-1 flex items-center gap-2">
<button type="button" onClick={job.reset} className="btn-secondary">
{t('admin.remnawave.geoCheck.changeRoute', 'Change route')}
</button>
<button type="button" onClick={job.retry} className="btn-primary">
{t('admin.remnawave.geoCheck.rerun', 'Run again')}
</button>
</div>
</div>
)}
{job.phase === 'done' && job.result && (
<GeoCheckReport
result={job.result}
nodeName={node.name}
fullscreen={fullscreen}
canFullscreen={canFullscreen}
canDownload={canDownload}
onToggleFullscreen={() => setFullscreenRequested((v) => !v)}
onRerun={job.retry}
/>
)}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,231 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoCheckResult } from '@/api/adminRemnawave';
import {
CheckIcon,
CodeIcon,
CollapseIcon,
CopyIcon,
DownloadIcon,
ExpandIcon,
EyeIcon,
RefreshIcon,
} from '@/components/icons';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/utils/clipboard';
import { GeoCheckImageViewer } from './GeoCheckImageViewer';
type ReportTab = 'report' | 'json';
const TOOLBAR_BUTTON =
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40';
interface GeoCheckReportProps {
result: GeoCheckResult;
nodeName: string;
fullscreen: boolean;
/**
* Показывать ли переключатель полного экрана. В Telegram Mini App окно и так
* во весь экран — режим лишь снимал отступы и лез под хром Telegram.
*/
canFullscreen: boolean;
/**
* Показывать ли скачивание. В webview Telegram атрибут `download`
* игнорируется: вместо сохранения файла webview уходит на blob и подменяет
* собой Mini App, вернуться можно только кнопкой «Назад». Штатный
* `downloadFile` из Bot API 8.0 тут не помогает — он требует HTTPS-ссылку
* на файл, а отчёт приходит base64 внутри JSON.
*/
canDownload: boolean;
onToggleFullscreen: () => void;
onRerun: () => void;
}
/**
* Готовый отчёт GeoCheck: картинка либо тот же отчёт в JSON.
*
* Картинка вставляется как `<img src="data:...">`, а не сырым SVG в разметку:
* отчёт несёт встроенный моноширинный шрифт и `<style>`, на которых держится
* выравнивание колонок, — санитайзер их выбросит и таблицы поедут. В `<img>`
* SVG рисуется в изолированном контексте без скриптов и внешних запросов, то
* есть это и безопаснее, и вернее по виду.
*/
export function GeoCheckReport({
result,
nodeName,
fullscreen,
canFullscreen,
canDownload,
onToggleFullscreen,
onRerun,
}: GeoCheckReportProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<ReportTab>('report');
const [copied, setCopied] = useState(false);
const imageSrc = useMemo(() => {
const image = result.image;
if (!image?.data) return null;
return `data:${image.media_type};${image.encoding},${image.data}`;
}, [result.image]);
const reportJson = useMemo(
() => (result.raw_report ? JSON.stringify(result.raw_report, null, 2) : null),
[result.raw_report],
);
const imageAlt = t('admin.remnawave.geoCheck.reportAlt', 'GeoCheck report for {{node}}', {
node: nodeName,
});
const handleCopy = async () => {
if (!reportJson) return;
await copyToClipboard(reportJson);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => {
const image = result.image;
if (!image?.data) return;
// Blob, а не прямая ссылка на data: URL — во встроенном webview Telegram
// скачивание по data: молча ничего не делает.
const binary = atob(image.data);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: image.media_type }));
const link = document.createElement('a');
link.href = url;
link.download = `geocheck-${nodeName.replace(/[^\w.-]+/g, '-').toLowerCase()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const tabs: Array<{ id: ReportTab; label: string; icon: ReactNode; enabled: boolean }> = [
{
id: 'report',
label: t('admin.remnawave.geoCheck.tab.report', 'Report'),
icon: <EyeIcon className="h-3.5 w-3.5" />,
enabled: Boolean(imageSrc),
},
{
id: 'json',
label: t('admin.remnawave.geoCheck.tab.json', 'JSON'),
icon: <CodeIcon className="h-3.5 w-3.5" />,
enabled: Boolean(reportJson),
},
];
const fullscreenLabel = fullscreen
? t('admin.remnawave.geoCheck.exitFullscreen', 'Exit fullscreen')
: t('admin.remnawave.geoCheck.fullscreen', 'Fullscreen');
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between gap-2">
<div className="flex gap-1 rounded-xl bg-dark-800/50 p-1">
{tabs
.filter((item) => item.enabled)
.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
aria-pressed={tab === item.id}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
tab === item.id
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
{canFullscreen && (
<button
type="button"
onClick={onToggleFullscreen}
disabled={!imageSrc}
className={TOOLBAR_BUTTON}
title={fullscreenLabel}
aria-label={fullscreenLabel}
aria-pressed={fullscreen}
>
{fullscreen ? (
<CollapseIcon className="h-4 w-4" />
) : (
<ExpandIcon className="h-4 w-4" />
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
disabled={!reportJson}
className={TOOLBAR_BUTTON}
title={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
aria-label={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
>
{copied ? (
<CheckIcon className="h-4 w-4 text-success-400" />
) : (
<CopyIcon className="h-4 w-4" />
)}
</button>
{canDownload && (
<button
type="button"
onClick={handleDownload}
disabled={!imageSrc}
className={TOOLBAR_BUTTON}
title={t('admin.remnawave.geoCheck.download', 'Download SVG')}
aria-label={t('admin.remnawave.geoCheck.download', 'Download SVG')}
>
<DownloadIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onRerun}
className={TOOLBAR_BUTTON}
title={t('admin.remnawave.geoCheck.rerun', 'Run again')}
aria-label={t('admin.remnawave.geoCheck.rerun', 'Run again')}
>
<RefreshIcon className="h-4 w-4" />
</button>
</div>
</div>
{result.message && (
<p className="mt-3 rounded-xl bg-warning-500/10 px-3 py-2 text-xs text-warning-400">
{result.message}
</p>
)}
{/* Высота задана явно: без неё зум-поверхности не от чего отталкиваться,
а модалка растягивалась бы под натуральный размер отчёта. */}
<div
className={cn(
'relative mt-3 overflow-hidden rounded-2xl border border-dark-700 bg-dark-950 [contain:paint]',
fullscreen ? 'min-h-0 flex-1' : 'h-[62dvh] min-h-[16rem]',
)}
>
{tab === 'report' && imageSrc && (
<GeoCheckImageViewer src={imageSrc} alt={imageAlt} fullscreen={fullscreen} />
)}
{tab === 'json' && reportJson && (
<pre className="h-full overflow-auto p-3 font-mono text-[11px] leading-relaxed text-dark-200">
{reportJson}
</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useTranslation } from 'react-i18next';
import type { NodeInfo } from '@/api/adminRemnawave';
import { GlobeIcon, NetworkIcon, SparklesIcon } from '@/components/icons';
import { cn } from '@/lib/utils';
import {
isRouteReady,
suggestedInterfaces,
suggestedIps,
type GeoCheckRouteMode,
} from './geoCheckRoute';
interface GeoCheckSetupProps {
node: NodeInfo;
mode: GeoCheckRouteMode;
value: string;
onModeChange: (mode: GeoCheckRouteMode) => void;
onValueChange: (value: string) => void;
}
/** Выбор маршрута, с которого гнать проверку: по умолчанию / IP / интерфейс. */
export function GeoCheckSetup({
node,
mode,
value,
onModeChange,
onValueChange,
}: GeoCheckSetupProps) {
const { t } = useTranslation();
const modes: Array<{ id: GeoCheckRouteMode; label: string }> = [
{ id: 'default', label: t('admin.remnawave.geoCheck.mode.default', 'Default') },
{ id: 'ip', label: t('admin.remnawave.geoCheck.mode.ip', 'IP address') },
{ id: 'interface', label: t('admin.remnawave.geoCheck.mode.interface', 'Interface') },
];
const ips = suggestedIps(node);
const interfaces = suggestedInterfaces(node);
const suggestions = mode === 'ip' ? ips : mode === 'interface' ? interfaces : [];
const hint =
mode === 'default'
? t('admin.remnawave.geoCheck.hint.default', 'The node will use its default outbound route.')
: mode === 'ip'
? t(
'admin.remnawave.geoCheck.hint.ip',
'Pick one of the node addresses or type any address.',
)
: t(
'admin.remnawave.geoCheck.hint.interface',
'Pick one of the node interfaces or type any name.',
);
const placeholder =
mode === 'default'
? t('admin.remnawave.geoCheck.placeholder.default', 'Automatic')
: mode === 'ip'
? '1.2.3.4'
: 'eth0';
const isInvalid = mode !== 'default' && value.trim().length > 0 && !isRouteReady(mode, value);
return (
<div className="space-y-4">
<div
role="tablist"
aria-label={t('admin.remnawave.geoCheck.mode.legend', 'Check route')}
className="flex gap-1 rounded-xl bg-dark-800/50 p-1"
>
{modes.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={mode === item.id}
onClick={() => onModeChange(item.id)}
className={cn(
'flex-1 whitespace-nowrap rounded-lg px-2 py-2 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
mode === item.id
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
)}
>
{item.label}
</button>
))}
</div>
<div>
<label htmlFor="geocheck-route-value" className="block text-sm font-medium text-dark-200">
{modes.find((item) => item.id === mode)?.label}
</label>
<p className="mt-0.5 text-xs text-dark-400">{hint}</p>
<div className="relative mt-2">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
{mode === 'default' ? (
<SparklesIcon className="h-4 w-4" />
) : mode === 'ip' ? (
<GlobeIcon className="h-4 w-4" />
) : (
<NetworkIcon className="h-4 w-4" />
)}
</span>
<input
id="geocheck-route-value"
type="text"
inputMode={mode === 'ip' ? 'numeric' : 'text'}
autoComplete="off"
spellCheck={false}
disabled={mode === 'default'}
value={mode === 'default' ? '' : value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={placeholder}
aria-invalid={isInvalid}
className={cn(
'input pl-9 font-mono disabled:cursor-not-allowed disabled:opacity-60',
isInvalid && 'input-error',
)}
/>
</div>
{isInvalid && (
<p className="mt-1.5 text-xs text-error-400">
{mode === 'ip'
? t('admin.remnawave.geoCheck.invalidIp', 'Enter a valid IPv4 or IPv6 address')
: t('admin.remnawave.geoCheck.invalidInterface', 'Enter a valid interface name')}
</p>
)}
{suggestions.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => onValueChange(suggestion)}
className={cn(
'max-w-full truncate rounded-lg px-2 py-1 font-mono text-[11px] transition-colors',
value.trim() === suggestion
? 'bg-accent-500/20 text-accent-300'
: 'bg-dark-700/60 text-dark-300 hover:bg-dark-700 hover:text-dark-100',
)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import type { NodeInfo } from '@/api/adminRemnawave';
import {
buildGeoCheckRequest,
isRouteReady,
isValidInterfaceName,
isValidIpAddress,
suggestedInterfaces,
suggestedIps,
} from './geoCheckRoute';
function node(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
uuid: 'n-1',
name: 'Germany #2',
address: '213.176.77.249',
is_connected: true,
is_disabled: false,
is_node_online: true,
is_xray_running: true,
users_online: 10,
xray_uptime: 0,
is_traffic_tracking_active: false,
consumption_multiplier: 1,
...overrides,
} as NodeInfo;
}
describe('isValidIpAddress', () => {
it.each([
'1.2.3.4',
'213.176.77.249',
'0.0.0.0',
'255.255.255.255',
'2a0b:4141:820:140d::2',
])('принимает %s', (value) => expect(isValidIpAddress(value)).toBe(true));
it.each([
'',
' ',
'not-an-ip',
'256.1.1.1',
'1.2.3',
'1.2.3.4/24',
'1.2.3.4 ; reboot',
])('отклоняет %s', (value) => expect(isValidIpAddress(value)).toBe(false));
it('игнорирует обрамляющие пробелы', () => {
expect(isValidIpAddress(' 1.2.3.4 ')).toBe(true);
});
});
describe('isValidInterfaceName', () => {
it.each(['eth0', 'ens3', 'wg0', 'br-lan', 'enp0s31f6'])('принимает %s', (value) =>
expect(isValidInterfaceName(value)).toBe(true));
it.each([
'',
'bad iface',
'eth0;reboot',
'../etc',
'-eth0',
'a'.repeat(33),
])('отклоняет %s', (value) => expect(isValidInterfaceName(value)).toBe(false));
});
describe('isRouteReady', () => {
it('режим по умолчанию не требует значения', () => {
expect(isRouteReady('default', '')).toBe(true);
});
it('режимы ip и interface требуют корректного значения', () => {
expect(isRouteReady('ip', '')).toBe(false);
expect(isRouteReady('ip', '1.2.3.4')).toBe(true);
expect(isRouteReady('interface', '')).toBe(false);
expect(isRouteReady('interface', 'ens3')).toBe(true);
});
});
describe('buildGeoCheckRequest', () => {
it('по умолчанию отправляет пустое тело', () => {
expect(buildGeoCheckRequest('default', 'ens3')).toEqual({});
});
it('отправляет ровно одно поле — иначе панель получит неоднозначный запрос', () => {
expect(buildGeoCheckRequest('ip', ' 1.2.3.4 ')).toEqual({ ip: '1.2.3.4' });
expect(buildGeoCheckRequest('interface', ' ens3 ')).toEqual({ interface: 'ens3' });
});
it('пустое значение в режиме ip/interface означает маршрут по умолчанию', () => {
expect(buildGeoCheckRequest('ip', ' ')).toEqual({});
});
});
describe('suggestedIps', () => {
it('ставит адрес ноды первым и не дублирует его', () => {
const result = suggestedIps(
node({
ips: [
{ ip: '213.176.77.249', status: 'OUTBOUND' },
{ ip: '2a0b:4141:820:140d::2', status: 'INBOUND' },
],
}),
);
expect(result).toEqual(['213.176.77.249', '2a0b:4141:820:140d::2']);
});
it('не предлагает заблокированные адреса', () => {
const result = suggestedIps(node({ ips: [{ ip: '10.0.0.1', status: 'BLOCKED' }] }));
expect(result).toEqual(['213.176.77.249']);
});
it('работает, когда панель не прислала ips', () => {
expect(suggestedIps(node())).toEqual(['213.176.77.249']);
});
});
describe('suggestedInterfaces', () => {
it('отбрасывает loopback', () => {
const result = suggestedInterfaces(
node({
system: {
info: { networkInterfaces: ['lo', 'ens3', 'wg0'] },
stats: {},
} as unknown as NodeInfo['system'],
}),
);
expect(result).toEqual(['ens3', 'wg0']);
});
it('без системной информации — пусто', () => {
expect(suggestedInterfaces(node())).toEqual([]);
});
});

View File

@@ -0,0 +1,55 @@
import type { GeoCheckRequest, NodeInfo } from '@/api/adminRemnawave';
/** Откуда гнать проверку. */
export type GeoCheckRouteMode = 'default' | 'ip' | 'interface';
/**
* Те же правила, что и на бэкенде (`GeocheckRequest`): значение уезжает в
* панель и дальше на узел, поэтому форма не должна пропускать мусор. Здесь
* проверка нужна не ради безопасности — она ради того, чтобы админ увидел
* ошибку сразу, а не через полминуты ожидания.
*/
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
const IPV6_RE = /^[0-9a-fA-F:]+$/;
const INTERFACE_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,31}$/;
export function isValidIpAddress(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (IPV4_RE.test(trimmed)) return true;
// IPv6 без полного разбора: достаточно набора символов и одного «::»-стиля
// разделителя. Точный разбор делает панель, здесь важно отсечь опечатки.
return trimmed.includes(':') && IPV6_RE.test(trimmed) && !trimmed.includes(':::');
}
export function isValidInterfaceName(value: string): boolean {
return INTERFACE_RE.test(value.trim());
}
/** Готово ли значение к отправке в выбранном режиме. */
export function isRouteReady(mode: GeoCheckRouteMode, value: string): boolean {
if (mode === 'default') return true;
if (mode === 'ip') return isValidIpAddress(value);
return isValidInterfaceName(value);
}
/** Тело запроса для выбранного режима. */
export function buildGeoCheckRequest(mode: GeoCheckRouteMode, value: string): GeoCheckRequest {
const trimmed = value.trim();
if (mode === 'ip' && trimmed) return { ip: trimmed };
if (mode === 'interface' && trimmed) return { interface: trimmed };
return {};
}
/** Адреса узла, которые имеет смысл предлагать как исходные. */
export function suggestedIps(node: NodeInfo): string[] {
const fromPanel = (node.ips ?? []).filter((entry) => entry.status !== 'BLOCKED').map((e) => e.ip);
// Адрес самой ноды панель в `ips` не всегда повторяет, а он самый ожидаемый.
const all = [node.address, ...fromPanel].filter(Boolean);
return Array.from(new Set(all));
}
/** Сетевые интерфейсы узла; служебный loopback предлагать бессмысленно. */
export function suggestedInterfaces(node: NodeInfo): string[] {
return (node.system?.info?.networkInterfaces ?? []).filter((name) => name !== 'lo');
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useRef, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { adminRemnawaveApi } from '@/api/adminRemnawave';
import type { GeoCheckRequest, GeoCheckResult } from '@/api/adminRemnawave';
import { getApiErrorMessage } from '@/utils/api-error';
/** Как часто спрашивать панель о готовности отчёта. */
const POLL_INTERVAL_MS = 2500;
/** Нода отвечает до минуты; после этого порога считаем, что не дождались. */
const POLL_TIMEOUT_MS = 180_000;
export type GeoCheckPhase = 'idle' | 'running' | 'done' | 'error';
/**
* Почему не получилось:
* - `request` — панель не приняла запуск (старая версия, нода офлайн, нет прав);
* - `timeout` — задача принята, но результата не дождались;
* - `failed` — проверка завершилась неудачей, подробности в `message`.
*/
export interface GeoCheckError {
kind: 'request' | 'timeout' | 'failed';
message: string | null;
}
export interface GeoCheckJob {
phase: GeoCheckPhase;
result: GeoCheckResult | null;
error: GeoCheckError | null;
/** Запускает проверку; повторный вызов забывает прошлую задачу. */
start: (body: GeoCheckRequest) => void;
/** Повторяет проверку с теми же параметрами маршрута. */
retry: () => void;
reset: () => void;
}
/**
* Жизненный цикл одной проверки GeoCheck.
*
* Проверка асинхронная: POST отдаёт `job_id`, дальше опрашиваем статус, пока
* задача не завершится или не выйдет время. Таймаут обязателен — иначе
* оставленная открытой модалка будет опрашивать панель бесконечно.
*/
export function useGeoCheckJob(nodeUuid: string): GeoCheckJob {
const [jobId, setJobId] = useState<string | null>(null);
const [requestError, setRequestError] = useState<string | null>(null);
const lastRequest = useRef<GeoCheckRequest>({});
const startedAt = useRef(0);
const startMutation = useMutation({
mutationFn: (body: GeoCheckRequest) => adminRemnawaveApi.startNodeGeoCheck(nodeUuid, body),
onSuccess: (data) => {
startedAt.current = Date.now();
setJobId(data.job_id);
},
onError: (error) => setRequestError(getApiErrorMessage(error, '')),
});
const jobQuery = useQuery({
queryKey: ['admin-remnawave-geocheck', jobId],
queryFn: () => adminRemnawaveApi.getGeoCheckJob(jobId as string),
enabled: Boolean(jobId),
gcTime: 0,
refetchInterval: (query) => {
if (Date.now() - startedAt.current > POLL_TIMEOUT_MS) return false;
const data = query.state.data;
if (!data) return POLL_INTERVAL_MS;
return data.is_completed || data.is_failed ? false : POLL_INTERVAL_MS;
},
});
const start = useCallback(
(body: GeoCheckRequest) => {
lastRequest.current = body;
setRequestError(null);
setJobId(null);
startMutation.mutate(body);
},
[startMutation],
);
const retry = useCallback(() => start(lastRequest.current), [start]);
const reset = useCallback(() => {
setJobId(null);
setRequestError(null);
startMutation.reset();
}, [startMutation]);
const job = jobQuery.data;
const settled = Boolean(job?.is_completed || job?.is_failed);
const timedOut = Boolean(jobId) && !settled && Date.now() - startedAt.current > POLL_TIMEOUT_MS;
// `is_failed` от панели и `success: false` внутри результата — разные вещи:
// первое значит «задача упала», второе — «нода ответила, но проверка не
// удалась». Админу в обоих случаях нужен `result.message`.
const failed = Boolean(job?.is_failed || (settled && job?.result && !job.result.success));
let phase: GeoCheckPhase = 'idle';
let error: GeoCheckError | null = null;
if (requestError !== null) {
phase = 'error';
error = { kind: 'request', message: requestError || null };
} else if (timedOut) {
phase = 'error';
error = { kind: 'timeout', message: null };
} else if (startMutation.isPending || (jobId && !settled)) {
phase = 'running';
} else if (settled) {
phase = failed ? 'error' : 'done';
if (failed) error = { kind: 'failed', message: job?.result?.message ?? null };
}
return {
phase,
result: job?.result ?? null,
error,
start,
retry,
reset,
};
}

View File

@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
onRemoveTraffic: (purchaseId: number) => Promise<void>;
onResetDevices: () => Promise<void>;
onCancelSbpRecurring: () => Promise<void>;
onDeleteSubscription: () => Promise<void>;
onDeleteDevice: (hwid: string) => Promise<void>;
onRenameDevice: (hwid: string) => Promise<void>;
onLoadDevices: () => Promise<void>;
@@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
onRemoveTraffic,
onResetDevices,
onCancelSbpRecurring,
onDeleteSubscription,
onDeleteDevice,
onRenameDevice,
onLoadDevices,
@@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
</div>
)}
{/* Delete this subscription — in multi-tariff mode spent trials
pile up in the card, and removing one used to be possible
only through the bulk-actions screen. */}
{hasPermission('users:subscription') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.deleteTitle')}
</div>
<div className="mt-0.5 text-xs text-dark-400">
{t('admin.users.detail.subscription.deleteHint')}
</div>
</div>
<button
// Per-subscription confirm key: an armed confirm must not
// survive switching to another subscription in the picker.
onClick={() =>
onInlineConfirm(`deleteSubscription_${selectedSub.id}`, onDeleteSubscription)
}
disabled={actionLoading}
className={`shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === `deleteSubscription_${selectedSub.id}`
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === `deleteSubscription_${selectedSub.id}`
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.subscription.deleteButton')}
</button>
</div>
</div>
)}
{/* Traffic Packages */}
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">

View File

@@ -151,16 +151,16 @@ export function SyncTab({
</div>
)}
{/* UUID info */}
{/* Panel identity */}
<div className="rounded-xl bg-dark-800/50 p-4">
{syncStatus?.subscription_tariff_name && (
<div className="mb-2 text-xs text-dark-500">{syncStatus.subscription_tariff_name}</div>
)}
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
<div className="mb-1 text-sm text-dark-400">Remnawave ID</div>
<div className="break-all font-mono text-sm text-dark-100">
{syncStatus?.remnawave_uuid ||
user.remnawave_uuid ||
t('admin.users.detail.sync.notLinked')}
{/* Remnawave 3.0.0 identifies a panel user by a numeric id, so 0 is not
a valid id but ?? would still render it — check for null explicitly. */}
{syncStatus?.remnawave_id ?? user.remnawave_id ?? t('admin.users.detail.sync.notLinked')}
</div>
</div>

View File

@@ -318,7 +318,7 @@ function ChatView({
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
className="whitespace-pre-wrap break-words text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}

View File

@@ -0,0 +1,143 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useHaptic } from '../../platform';
import { useTheme } from '../../hooks/useTheme';
import { useTrafficZone } from '../../hooks/useTrafficZone';
import { getGlassColors } from '../../utils/glassTheme';
import { HoverBorderGradient } from '../ui/hover-border-gradient';
interface ConnectDeviceTileProps {
subscription: {
id: number;
device_limit: number;
subscription_url?: string | null;
};
connectedDevices: number;
/** Процент израсходованного трафика — от него зависит акцентный цвет плитки. */
usedPercent?: number;
}
/**
* Плитка «Подключить устройство».
*
* Живёт и в карточке активной подписки, и на главной: пользователь,
* которому подписку выдал бонус рекламной кампании, попадает на главную
* с готовым доступом — и без этой плитки не понимает, что делать дальше.
*/
export default function ConnectDeviceTile({
subscription,
connectedDevices,
usedPercent = 0,
}: ConnectDeviceTileProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const haptic = useHaptic();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const zone = useTrafficZone(usedPercent);
const isAtDeviceLimit =
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
if (!subscription.subscription_url) return null;
return (
<HoverBorderGradient
as="button"
accentColor={zone.mainHex}
disabled={isAtDeviceLimit}
onClick={() => {
if (isAtDeviceLimit) {
haptic.notification('error');
return;
}
navigate(`/connection?sub=${subscription.id}`);
}}
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300 ${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
data-onboarding="connect-devices"
style={{ fontFamily: 'inherit' }}
>
{/* Monitor icon */}
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={zone.mainVar}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M12 17v4M8 21h8" />
<path d="M12 8v4M10 10h4" opacity="0.7" />
</svg>
</div>
{/* Text */}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold tracking-tight text-dark-50">
{t('dashboard.connectDevice')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{subscription.device_limit === 0
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
: t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
</div>
{isAtDeviceLimit && (
<div
className="mt-1 text-[10px] font-medium"
style={{ color: 'rgb(var(--color-warning-400))' }}
>
{t('dashboard.deviceLimitReached')}
</div>
)}
</div>
{/* Device indicator */}
{subscription.device_limit === 0 ? (
<div className="flex flex-shrink-0 items-center text-lg text-dark-50/40" aria-hidden="true">
</div>
) : subscription.device_limit <= 10 ? (
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
{Array.from({ length: subscription.device_limit }, (_, i) => (
<div
key={i}
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
style={{
background: i < connectedDevices ? zone.mainVar : g.textGhost,
boxShadow: i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
}}
/>
))}
</div>
) : (
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
<div
className="h-[6px] w-full overflow-hidden rounded-full"
style={{ background: g.textGhost }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
background: zone.mainVar,
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
minWidth: connectedDevices > 0 ? '4px' : '0px',
}}
/>
</div>
</div>
)}
</HoverBorderGradient>
);
}

View File

@@ -1,18 +1,16 @@
import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { Link } from 'react-router';
import type { UseMutationResult } from '@tanstack/react-query';
import TrafficProgressBar from './TrafficProgressBar';
import Sparkline from './Sparkline';
import ConnectDeviceTile from './ConnectDeviceTile';
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
import { useTheme } from '../../hooks/useTheme';
import { useTrafficZone } from '../../hooks/useTrafficZone';
import { formatTraffic } from '../../utils/formatTraffic';
import { getGlassColors } from '../../utils/glassTheme';
import { HoverBorderGradient } from '../ui/hover-border-gradient';
import { CalendarIcon, RefreshIcon } from '@/components/icons';
import { useHaptic } from '../../platform';
import type { Subscription } from '../../types';
interface SubscriptionCardActiveProps {
@@ -35,7 +33,6 @@ export default function SubscriptionCardActive({
connectedDevices,
}: SubscriptionCardActiveProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
@@ -44,10 +41,6 @@ export default function SubscriptionCardActive({
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
const zone = useTrafficZone(usedPercent);
const animatedPercent = useAnimatedNumber(usedPercent);
const haptic = useHaptic();
const isAtDeviceLimit =
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
const daysLeft = subscription.days_left;
@@ -161,109 +154,11 @@ export default function SubscriptionCardActive({
</div>
{/* ─── Connect Device Button ─── */}
{subscription.subscription_url && (
<HoverBorderGradient
as="button"
accentColor={zone.mainHex}
disabled={isAtDeviceLimit}
onClick={() => {
if (isAtDeviceLimit) {
haptic.notification('error');
return;
}
navigate(`/connection?sub=${subscription.id}`);
}}
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
data-onboarding="connect-devices"
style={{ fontFamily: 'inherit' }}
>
{/* Monitor icon */}
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={zone.mainVar}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M12 17v4M8 21h8" />
<path d="M12 8v4M10 10h4" opacity="0.7" />
</svg>
</div>
{/* Text */}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold tracking-tight text-dark-50">
{t('dashboard.connectDevice')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{subscription.device_limit === 0
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
: t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
</div>
{isAtDeviceLimit && (
<div
className="mt-1 text-[10px] font-medium"
style={{ color: 'rgb(var(--color-warning-400))' }}
>
{t('dashboard.deviceLimitReached')}
</div>
)}
</div>
{/* Device indicator */}
{subscription.device_limit === 0 ? (
<div
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
aria-hidden="true"
>
</div>
) : subscription.device_limit <= 10 ? (
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
{Array.from({ length: subscription.device_limit }, (_, i) => (
<div
key={i}
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
style={{
background: i < connectedDevices ? zone.mainVar : g.textGhost,
boxShadow:
i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
}}
/>
))}
</div>
) : (
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
<div
className="h-[6px] w-full overflow-hidden rounded-full"
style={{ background: g.textGhost }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
background: zone.mainVar,
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
minWidth: connectedDevices > 0 ? '4px' : '0px',
}}
/>
</div>
</div>
)}
</HoverBorderGradient>
)}
<ConnectDeviceTile
subscription={subscription}
connectedDevices={connectedDevices}
usedPercent={usedPercent}
/>
{/* ─── Stats row: Tariff + Days Left ─── */}
<div className="mb-5 flex gap-2.5">

View File

@@ -1,4 +1,9 @@
import {
PiArrowsInSimple,
PiArrowsOutSimple,
PiCode,
PiMapTrifold,
PiNetwork,
PiSlidersHorizontal,
PiWrench,
PiBookOpen,
@@ -264,6 +269,27 @@ export const ExternalLinkIcon = ({ className }: IconProps) => (
<PiArrowSquareOut className={cn('h-5 w-5', className)} />
);
export const CodeIcon = ({ className }: IconProps) => (
<PiCode className={cn('h-5 w-5', className)} />
);
export const CollapseIcon = ({ className }: IconProps) => (
<PiArrowsInSimple className={cn('h-5 w-5', className)} />
);
export const ExpandIcon = ({ className }: IconProps) => (
<PiArrowsOutSimple className={cn('h-5 w-5', className)} />
);
/** GeoCheck — проверка геоданных ноды (Remnawave 3.3.0). */
export const GeoCheckIcon = ({ className }: IconProps) => (
<PiMapTrifold className={cn('h-5 w-5', className)} />
);
export const NetworkIcon = ({ className }: IconProps) => (
<PiNetwork className={cn('h-5 w-5', className)} />
);
export const EyeIcon = ({ className }: IconProps) => <PiEye className={cn('h-5 w-5', className)} />;
export const FileTextIcon = ({ className }: IconProps) => (

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { subscriptionApi } from '@/api/subscription';
import { DevicesIcon, PlusIcon, TrashIcon, WarningIcon } from '@/components/icons';
import { ResponsiveSheet } from '@/components/ui/ResponsiveSheet';
import { getApiErrorMessage } from '@/utils/api-error';
interface DeviceLimitSheetProps {
isOpen: boolean;
onClose: () => void;
subscriptionId: number;
subscriptionName: string;
deviceLimit: number;
isTrial: boolean;
devices: Array<{
hwid: string;
platform: string;
device_model: string;
local_name?: string | null;
}>;
/** Уводит на страницу подписки, где живёт докупка слотов. */
onOpenSubscription: () => void;
}
/**
* Почему нельзя подключить ещё одно устройство и что с этим делать.
*
* Открывается из подвала карточки, когда слоты кончились. Заблокировать
* кнопку было бы проще, но человек остался бы без выхода: реальных действий
* тут два — освободить слот или добавить ещё.
*/
export function DeviceLimitSheet({
isOpen,
onClose,
subscriptionId,
subscriptionName,
deviceLimit,
isTrial,
devices,
onOpenSubscription,
}: DeviceLimitSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
// Докупка слотов недоступна на тестовой подписке и при безлимите — ровно
// те же условия, что у блока докупки на странице подписки.
const canAddSlots = !isTrial && deviceLimit !== 0;
const disconnect = useMutation({
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
onClose();
},
onError: (err) =>
setError(getApiErrorMessage(err, t('common.error', 'Не удалось выполнить действие'))),
});
return (
<ResponsiveSheet
isOpen={isOpen}
onClose={onClose}
title={t('subscription.connectFooter.full', 'Все слоты заняты')}
>
<div className="space-y-4 px-4 pb-6 sm:px-5">
<div className="flex items-start gap-3 rounded-2xl bg-warning-400/10 p-3.5">
<WarningIcon className="mt-0.5 h-5 w-5 shrink-0 text-warning-400" />
<p className="text-[13px] leading-snug text-dark-200">
{canAddSlots
? t('subscription.connectFooter.limitExplained', {
defaultValue:
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.',
name: subscriptionName,
count: deviceLimit,
})
: t('subscription.connectFooter.limitExplainedNoTopup', {
defaultValue:
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.',
name: subscriptionName,
count: deviceLimit,
})}
</p>
</div>
<div>
<div className="mb-2 text-[11px] uppercase tracking-wider text-dark-500">
{t('subscription.connectFooter.connectedDevices', 'Подключённые устройства')}
</div>
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.hwid}
className="flex items-center gap-3 rounded-xl bg-dark-800/60 px-3 py-2.5"
>
<DevicesIcon className="h-4 w-4 shrink-0 opacity-40" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-dark-100">
{device.local_name || device.device_model || device.platform}
</div>
<div className="text-[11px] text-dark-500">{device.platform}</div>
</div>
<button
type="button"
disabled={disconnect.isPending}
onClick={() => disconnect.mutate(device.hwid)}
className="flex min-h-[36px] shrink-0 items-center gap-1.5 rounded-lg bg-error-500/10 px-2.5 text-[12px] font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
<TrashIcon className="h-3.5 w-3.5" />
{t('subscription.connectFooter.disconnect', 'Отключить')}
</button>
</div>
))}
</div>
</div>
{error && (
<p className="rounded-xl bg-error-500/10 px-3 py-2 text-[12px] text-error-400">{error}</p>
)}
{canAddSlots ? (
<button type="button" onClick={onOpenSubscription} className="btn-primary w-full">
<PlusIcon className="h-4 w-4" />
{t('subscription.connectFooter.addSlots', 'Добавить слоты')}
</button>
) : (
<p className="rounded-xl bg-dark-800/60 px-3 py-2.5 text-[12px] leading-snug text-dark-400">
{t(
'subscription.connectFooter.noTopupHint',
'На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф.',
)}
</p>
)}
</div>
</ResponsiveSheet>
);
}

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import { ChevronRightIcon, DevicesIcon, WarningIcon } from '@/components/icons';
import { cn } from '@/lib/utils';
import type { ConnectFooterState } from './connectFooterState';
interface SubscriptionConnectFooterProps {
state: ConnectFooterState;
/** Цвет разделителя берём у карточки, чтобы подвал читался её частью. */
borderColor: string;
mutedColor: string;
onConnect: () => void;
onManage: () => void;
}
/**
* Подвал карточки подписки: подключить устройство либо разобраться с лимитом.
*
* Отдельная зона нажатия внутри карточки, поэтому карточка снаружи — не
* `<button>`, а обёртка: вложенные кнопки невалидны и ломаются в браузере.
*/
export function SubscriptionConnectFooter({
state,
borderColor,
mutedColor,
onConnect,
onManage,
}: SubscriptionConnectFooterProps) {
const { t } = useTranslation();
if (state.kind === 'hidden') return null;
if (state.kind === 'loading') {
return (
<div
className="flex items-center gap-2.5 border-t px-4 py-2.5"
style={{ borderColor }}
aria-busy="true"
>
{/* Высоту задаёт невидимый текст с той же типографикой, что у готового
подвала. Подбирать её числом нельзя: строчный бокс даёт дробные
пиксели, и карточка дёргается при появлении данных. */}
<div className="flex w-full items-center gap-2.5">
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-dark-700/60" />
<div className="h-3.5 w-40 animate-pulse rounded bg-dark-700/60" />
<div className="ml-auto h-3 w-10 animate-pulse rounded bg-dark-700/60" />
<span aria-hidden className="w-0 overflow-hidden text-[13px] font-medium">
&nbsp;
</span>
</div>
</div>
);
}
const isFull = state.kind === 'full';
const highlight = state.kind === 'connect' && state.highlight;
const counter =
state.kind === 'connect' && state.unlimited
? `${state.used} · ∞`
: `${state.used} / ${state.limit}`;
return (
<button
type="button"
onClick={isFull ? onManage : onConnect}
className="flex w-full items-center gap-2.5 border-t px-4 py-2.5 text-left transition-colors active:bg-accent-500/10"
style={{ borderColor }}
>
{isFull ? (
<WarningIcon className="h-4 w-4 shrink-0 text-warning-400/80" />
) : (
<DevicesIcon
className={cn('h-4 w-4 shrink-0', highlight ? 'text-accent-400' : 'opacity-40')}
/>
)}
<span
className={cn('text-[13px] font-medium', highlight && 'text-accent-400')}
style={highlight ? undefined : { color: mutedColor }}
>
{isFull
? t('subscription.connectFooter.full', 'Все слоты заняты')
: t('subscription.connectFooter.connect', 'Подключить устройство')}
</span>
<span
className={cn('ml-auto text-[11px] tabular-nums', isFull && 'text-warning-400/80')}
style={isFull ? undefined : { color: mutedColor }}
>
{counter}
</span>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 opacity-30" />
</button>
);
}

View File

@@ -4,6 +4,8 @@ import { getGlassColors } from '../../utils/glassTheme';
import { useHaptic } from '../../platform';
import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons';
import type { SubscriptionListItem } from '../../types';
import { connectFooterState } from './connectFooterState';
import { SubscriptionConnectFooter } from './SubscriptionConnectFooter';
function formatDate(iso: string | null, locale?: string): string {
if (!iso) return '—';
@@ -68,9 +70,20 @@ function StatusBadge({
export default function SubscriptionListCard({
subscription,
onClick,
connect,
}: {
subscription: SubscriptionListItem;
onClick: () => void;
/**
* Подключение устройства прямо из карточки. Задаётся только на главной:
* список «Все подписки» остаётся простым перечнем без действий.
*/
connect?: {
/** `undefined`, пока число устройств не загрузилось. */
connectedDevices: number | undefined;
onConnect: () => void;
onManage: () => void;
};
}) {
const { t, i18n } = useTranslation();
const { isDark } = useTheme();
@@ -123,89 +136,113 @@ export default function SubscriptionListCard({
: 'rgba(255,59,92,0.03)'
: g.cardBg;
const footer = connect
? connectFooterState({
status: subscription.status,
subscriptionUrl: subscription.subscription_url,
deviceLimit: subscription.device_limit,
connected: connect.connectedDevices,
})
: { kind: 'hidden' as const };
// Подвал — своя зона нажатия, поэтому карточка снаружи не `<button>`:
// вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо.
return (
<button
onClick={handleClick}
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
<div
className="overflow-hidden rounded-2xl border transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
style={{ background: bgColor, borderColor }}
>
{/* Header: tariff name + status badge + chevron */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
</div>
{/* Traffic mini progress bar */}
{isActive && (
<div className="mt-3">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
{t('subscription.traffic', 'Трафик')}
</span>
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
{isUnlimited
? '∞'
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
<button onClick={handleClick} className="w-full p-4 text-left">
{/* Header: tariff name + status badge + chevron */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div>
{!isUnlimited && (
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
<div
className={`h-full rounded-full transition-all ${trafficColor}`}
style={{ width: `${Math.max(1, trafficPercent)}%` }}
/>
</div>
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
</div>
)}
{/* Stats row */}
<div
className="mt-2.5 flex items-center gap-4 text-[12px]"
style={{ color: g.textSecondary }}
>
<span className="flex items-center gap-1">
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
{subscription.device_limit}
</span>
<span className="flex items-center gap-1">
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
{formatDate(subscription.end_date, i18n.language)}
</span>
{!isTrial &&
(() => {
const isDaily = subscription.is_daily;
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
const label = isDaily
? t('subscription.dailyAutoCharge', 'Автосписание')
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
>
{enabled ? (
<CheckIcon className="h-3 w-3" />
) : (
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{label}
{/* Traffic mini progress bar */}
{isActive && (
<div className="mt-3">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
{t('subscription.traffic', 'Трафик')}
</span>
);
})()}
</div>
</button>
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
{isUnlimited
? '∞'
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
<div
className={`h-full rounded-full transition-all ${trafficColor}`}
style={{ width: `${Math.max(1, trafficPercent)}%` }}
/>
</div>
)}
</div>
)}
{/* Stats row */}
<div
className="mt-2.5 flex items-center gap-4 text-[12px]"
style={{ color: g.textSecondary }}
>
{footer.kind === 'hidden' && (
<span className="flex items-center gap-1">
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
{subscription.device_limit}
</span>
)}
<span className="flex items-center gap-1">
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
{formatDate(subscription.end_date, i18n.language)}
</span>
{!isTrial &&
(() => {
const isDaily = subscription.is_daily;
const enabled = isDaily
? !subscription.is_daily_paused
: subscription.autopay_enabled;
const label = isDaily
? t('subscription.dailyAutoCharge', 'Автосписание')
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
>
{enabled ? (
<CheckIcon className="h-3 w-3" />
) : (
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{label}
</span>
);
})()}
</div>
</button>
<SubscriptionConnectFooter
state={footer}
borderColor={borderColor}
mutedColor={g.textSecondary}
onConnect={() => connect?.onConnect()}
onManage={() => connect?.onManage()}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { connectFooterState } from './connectFooterState';
const base = {
status: 'active',
subscriptionUrl: 'https://example.invalid/sub',
deviceLimit: 5,
connected: 2,
};
describe('connectFooterState', () => {
it('обычная подписка со свободными слотами зовёт подключить', () => {
expect(connectFooterState(base)).toEqual({
kind: 'connect',
used: 2,
limit: 5,
unlimited: false,
highlight: false,
});
});
it('подсвечивает подписку без единого устройства — только там действие и нужно', () => {
const state = connectFooterState({ ...base, connected: 0 });
expect(state).toMatchObject({ kind: 'connect', highlight: true });
});
it('исчерпанный лимит — это не «нельзя», а повод управлять устройствами', () => {
expect(connectFooterState({ ...base, connected: 5 })).toEqual({
kind: 'full',
used: 5,
limit: 5,
});
});
it('устройств больше лимита (лимит понизили) — тоже полный', () => {
expect(connectFooterState({ ...base, connected: 7 })).toMatchObject({ kind: 'full' });
});
it('нулевой лимит означает безлимит, а не запрет', () => {
expect(connectFooterState({ ...base, deviceLimit: 0, connected: 12 })).toEqual({
kind: 'connect',
used: 12,
limit: 0,
unlimited: true,
highlight: false,
});
});
it('пока счётчик не пришёл — загрузка, а не догадка о состоянии', () => {
expect(connectFooterState({ ...base, connected: undefined })).toEqual({ kind: 'loading' });
});
it.each(['expired', 'disabled'])('у подписки со статусом %s подключать нечего', (status) => {
expect(connectFooterState({ ...base, status })).toEqual({ kind: 'hidden' });
});
it.each(['active', 'trial', 'limited'])('статус %s подвал показывает', (status) => {
expect(connectFooterState({ ...base, status }).kind).toBe('connect');
});
it.each([null, undefined, ''])('без ссылки на подписку (%s) подвала нет', (subscriptionUrl) => {
expect(connectFooterState({ ...base, subscriptionUrl })).toEqual({ kind: 'hidden' });
});
it('отсутствие ссылки важнее незагруженного счётчика', () => {
expect(connectFooterState({ ...base, subscriptionUrl: null, connected: undefined })).toEqual({
kind: 'hidden',
});
});
});

View File

@@ -0,0 +1,61 @@
/**
* Состояние подвала карточки подписки — «подключить устройство».
*
* Вынесено из компонента отдельно, потому что правил тут больше, чем кажется:
* безлимит и исчерпанный лимит выражаются одним и тем же полем `device_limit`,
* а незагруженный счётчик нельзя путать с нулём подключённых устройств.
*/
export type ConnectFooterState =
/** Подключать нечего: подписка истекла или у неё нет ссылки. */
| { kind: 'hidden' }
/** Счётчик устройств ещё не пришёл — показываем скелетон, а не догадку. */
| { kind: 'loading' }
/** Есть куда подключаться. `highlight` — ни одного устройства, зовём заметнее. */
| { kind: 'connect'; used: number; limit: number; unlimited: boolean; highlight: boolean }
/** Слоты кончились — ведём разбираться, а не блокируем. */
| { kind: 'full'; used: number; limit: number };
/** Статусы, при которых доступ ещё работает и устройство есть смысл подключать. */
const CONNECTABLE_STATUSES = new Set(['active', 'trial', 'limited']);
export interface ConnectFooterInput {
status: string;
/** Ссылка на подписку из панели: без неё подключать не к чему. */
subscriptionUrl: string | null | undefined;
/** 0 означает «без лимита устройств», а не «нельзя ни одного». */
deviceLimit: number;
/** `undefined`, пока запрос числа устройств не завершился. */
connected: number | undefined;
}
export function connectFooterState({
status,
subscriptionUrl,
deviceLimit,
connected,
}: ConnectFooterInput): ConnectFooterState {
if (!subscriptionUrl || !CONNECTABLE_STATUSES.has(status)) {
return { kind: 'hidden' };
}
if (connected === undefined) {
return { kind: 'loading' };
}
const unlimited = deviceLimit === 0;
// `>=`, а не `===`: лимит можно понизить ниже числа уже подключённых
// устройств, и тогда слотов «минус один» — состояние всё равно полное.
if (!unlimited && connected >= deviceLimit) {
return { kind: 'full', used: connected, limit: deviceLimit };
}
return {
kind: 'connect',
used: connected,
limit: deviceLimit,
unlimited,
highlight: connected === 0,
};
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { XCloseIcon } from '@/components/icons';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { Sheet } from './Sheet';
/** Ширина, с которой нижний шит перестаёт быть уместным. */
const DESKTOP_QUERY = '(min-width: 640px)';
function useIsDesktop(): boolean {
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.matchMedia(DESKTOP_QUERY).matches,
);
useEffect(() => {
const query = window.matchMedia(DESKTOP_QUERY);
const onChange = (event: MediaQueryListEvent) => setIsDesktop(event.matches);
query.addEventListener('change', onChange);
setIsDesktop(query.matches);
return () => query.removeEventListener('change', onChange);
}, []);
return isDesktop;
}
interface ResponsiveSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
/**
* Нижний шит на телефоне, обычное окно на десктопе.
*
* Шит — мобильная идиома: ручка перетаскивания мышью бессмысленна, а
* прижатая к нижнему краю плашка на широком экране оставляет центр пустым
* и обрезает нижние скругления. На десктопе то же содержимое показывается
* центрированным окном с ловушкой фокуса и закрытием по Escape.
*/
export function ResponsiveSheet({ isOpen, onClose, title, children }: ResponsiveSheetProps) {
const { t } = useTranslation();
const isDesktop = useIsDesktop();
// Ловушка нужна только своей ветке: у Sheet она уже своя.
const dialogRef = useFocusTrap<HTMLDivElement>(isOpen && isDesktop, { onEscape: onClose });
if (!isOpen) return null;
if (!isDesktop) {
return (
<Sheet isOpen onClose={onClose} title={title}>
{children}
</Sheet>
);
}
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-dark-950/70 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
>
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
<h3 className="text-lg font-semibold text-dark-50">{title}</h3>
<button
type="button"
onClick={onClose}
aria-label={t('common.close', 'Закрыть')}
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
<XCloseIcon />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
</div>,
document.body,
);
}

View File

@@ -13,11 +13,11 @@ import {
enableVerticalSwipes as sdkEnableVerticalSwipes,
expandViewport,
retrieveLaunchParams,
retrieveRawInitData,
themeParamsState,
closeMiniApp as sdkCloseMiniApp,
postEvent,
} from '@telegram-apps/sdk-react';
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
@@ -90,11 +90,7 @@ export function isTelegramMobile(): boolean {
}
export function getTelegramInitData(): string | null {
try {
return retrieveRawInitData() || null;
} catch {
return null;
}
return readTelegramInitData();
}
function isDarkHexColor(hex: string): boolean {

View File

@@ -207,6 +207,9 @@
"orOpenInApp": "Or open the bot in the app",
"loginFailed": "Login Failed",
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
"deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:",
"loginWithBot": "Login via bot",
"backToWidget": "Back to widget login",
"openBotToLogin": "Open bot to sign in",
"waitingForConfirmation": "Waiting for confirmation...",
"deepLinkExpired": "Link expired. Please try again.",
@@ -736,6 +739,16 @@
"confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge",
"defaultName": "Subscription",
"connectFooter": {
"connect": "Connect a device",
"full": "All slots are in use",
"connectedDevices": "Connected devices",
"disconnect": "Disconnect",
"addSlots": "Add slots",
"limitExplained": "The «{{name}}» plan gives {{count}} slots and all are in use. Free one up or add more.",
"limitExplainedNoTopup": "The «{{name}}» plan gives {{count}} slots and all are in use. Free up a slot to continue.",
"noTopupHint": "Slots cannot be bought on a trial subscription — free one up or switch to a paid plan."
},
"delete": "Delete",
"deleteTitle": "Delete subscription",
"get_config": "Get config",
@@ -1131,7 +1144,12 @@
"contactSupport": "Please contact {{username}} for support",
"contactUs": "Contact Support",
"create_ticket": "Create ticket",
"writeButton": "Message"
"writeButton": "Message",
"errors": {
"alreadyOpenTicket": "You already have an open ticket. Please continue the conversation there — a new ticket can be created once the current one is closed.",
"createFailed": "Could not create the ticket. Please try again.",
"replyFailed": "Could not send the reply. Please try again."
}
},
"wheel": {
"title": "Fortune Wheel",
@@ -1833,6 +1851,47 @@
},
"usersOnlineCount": "{{count}} online"
},
"geoCheck": {
"title": "GeoCheck",
"start": "Run check",
"running": "Running the geo check",
"runningHint": "The node is testing its connection — this usually takes up to a minute.",
"changeRoute": "Change route",
"rerun": "Run again",
"reportAlt": "GeoCheck report for {{node}}",
"invalidIp": "Enter a valid IPv4 or IPv6 address",
"invalidInterface": "Enter a valid interface name",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit fullscreen",
"copyJson": "Copy JSON report",
"download": "Download SVG",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"zoomReset": "Reset zoom",
"mode": {
"legend": "Check route",
"default": "Default",
"ip": "IP address",
"interface": "Interface"
},
"hint": {
"default": "The node will use its default outbound route.",
"ip": "Pick one of the node addresses or type any address.",
"interface": "Pick one of the node interfaces or type any name."
},
"placeholder": {
"default": "Automatic"
},
"tab": {
"report": "Report",
"json": "JSON"
},
"error": {
"title": "Check failed",
"timeout": "The node did not answer in time. Try running the check again.",
"generic": "The check could not be completed."
}
},
"squads": {
"synced": "Synced",
"notSynced": "Not synced",
@@ -2306,9 +2365,16 @@
"sys_maintenance": "Maintenance",
"sys_backup": "Backup",
"sys_version": "Version",
"sys_logging": "Logging"
"sys_logging": "Logging",
"iface_info": "Legal documents",
"iface_menu": "Menu button",
"sys_cabinet": "Cabinet",
"sys_other": "Other settings"
},
"categories": {
"CABINET": "Cabinet",
"INFO_PAGES": "Legal documents and FAQ",
"MENU": "Menu button",
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
@@ -3293,7 +3359,10 @@
"save": "Save",
"defaultTrialTariff": "Default trial tariff",
"selectTariff": "Select tariff",
"tariff": "Tariff"
"tariff": "Tariff",
"includeTraffic": "Traffic",
"trafficAmount": "Traffic amount",
"gb": "GB"
},
"stats": {
"title": "Promo code statistics",
@@ -3367,7 +3436,8 @@
"daysRequired": "Number of days must be greater than 0",
"groupRequired": "Select a discount group",
"discountPercentInvalid": "Discount percent must be between 1 and 100",
"discountHoursRequired": "Specify the discount validity period in hours"
"discountHoursRequired": "Specify the discount validity period in hours",
"trafficRequired": "Traffic amount must be greater than 0"
}
},
"promoGroups": {
@@ -3449,7 +3519,8 @@
"byDate": "By date",
"byBalance": "By balance",
"byActivity": "By activity",
"bySpent": "By spending"
"bySpent": "By spending",
"byExpiry": "By expiry"
},
"pagination": {
"showing": "Showing {{from}}-{{to}} of {{total}}"
@@ -3639,7 +3710,11 @@
"sbpCancelled": "SBP auto-payment disabled",
"sbpStatus_PENDING": "Pending",
"sbpStatus_ACTIVE": "Active",
"sbpStatus_PAST_DUE": "Payment failed"
"sbpStatus_PAST_DUE": "Payment failed",
"deleteTitle": "Delete subscription",
"deleteHint": "The subscription and its devices will be removed permanently",
"deleteButton": "Delete subscription",
"deleted": "Subscription deleted"
},
"balance": {
"current": "Current balance",

View File

@@ -199,6 +199,9 @@
"orOpenInApp": "یا ربات را در برنامه باز کنید",
"loginFailed": "ورود ناموفق",
"telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:",
"deepLinkIntro": "ورود را مستقیماً در ربات تأیید کنید — بدون نیاز به شماره تلفن:",
"loginWithBot": "ورود از طریق ربات",
"backToWidget": "بازگشت به ورود با ویجت",
"openBotToLogin": "باز کردن ربات برای ورود",
"waitingForConfirmation": "در انتظار تایید...",
"deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.",
@@ -641,6 +644,16 @@
},
"dailyAutoCharge": "برداشت خودکار روزانه",
"defaultName": "اشتراک",
"connectFooter": {
"connect": "اتصال دستگاه",
"full": "همه ظرفیت‌ها پر است",
"connectedDevices": "دستگاه‌های متصل",
"disconnect": "قطع اتصال",
"addSlots": "افزودن ظرفیت",
"limitExplained": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یکی را آزاد کنید یا ظرفیت اضافه کنید.",
"limitExplainedNoTopup": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یک ظرفیت را آزاد کنید.",
"noTopupHint": "روی اشتراک آزمایشی نمی‌توان ظرفیت خرید — یکی را آزاد کنید یا طرح پولی بگیرید."
},
"delete": "حذف",
"deleteTitle": "حذف اشتراک",
"get_config": "دریافت پیکربندی",
@@ -1004,7 +1017,12 @@
"ticketsDisabled": "تیکت‌ها غیرفعال است",
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
"create_ticket": "ایجاد تیکت",
"writeButton": "پیام"
"writeButton": "پیام",
"errors": {
"alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن می‌توانید تیکت جدید ایجاد کنید.",
"createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.",
"replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید."
}
},
"wheel": {
"title": "چرخ شانس",
@@ -1969,9 +1987,16 @@
"sys_maintenance": "نگهداری",
"sys_backup": "پشتیبان‌گیری",
"sys_version": "نسخه",
"sys_logging": "لاگ‌ها"
"sys_logging": "لاگ‌ها",
"iface_info": "اسناد حقوقی",
"iface_menu": "دکمه منو",
"sys_cabinet": "کابین",
"sys_other": "سایر تنظیمات"
},
"categories": {
"CABINET": "کابین",
"INFO_PAGES": "اسناد حقوقی و پرسش‌های متداول",
"MENU": "دکمه منو",
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
@@ -2803,7 +2828,10 @@
"save": "ذخیره",
"defaultTrialTariff": "تعرفه پیش‌فرض آزمایشی",
"selectTariff": "تعرفه را انتخاب کنید",
"tariff": "تعرفه"
"tariff": "تعرفه",
"includeTraffic": "ترافیک",
"trafficAmount": "حجم ترافیک",
"gb": "گیگابایت"
},
"stats": {
"title": "آمار کد تخفیف",
@@ -2875,7 +2903,8 @@
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
"groupRequired": "یک گروه تخفیف انتخاب کنید",
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید"
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید",
"trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد"
}
},
"promoGroups": {
@@ -2956,7 +2985,8 @@
"byDate": "بر اساس تاریخ",
"byBalance": "بر اساس موجودی",
"byActivity": "بر اساس فعالیت",
"bySpent": "بر اساس هزینه"
"bySpent": "بر اساس هزینه",
"byExpiry": "بر اساس انقضا"
},
"pagination": {
"showing": "نمایش {{from}}-{{to}} از {{total}}"
@@ -3098,7 +3128,11 @@
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
"sbpStatus_PENDING": "در انتظار",
"sbpStatus_ACTIVE": "فعال",
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
"sbpStatus_PAST_DUE": "پرداخت ناموفق",
"deleteTitle": "حذف اشتراک",
"deleteHint": "اشتراک و دستگاه‌های آن برای همیشه حذف می‌شوند",
"deleteButton": "حذف اشتراک",
"deleted": "اشتراک حذف شد"
},
"balance": {
"current": "موجودی فعلی",
@@ -3440,6 +3474,47 @@
},
"usersOnlineCount": "{{count}} آنلاین"
},
"geoCheck": {
"title": "GeoCheck",
"start": "اجرای بررسی",
"running": "در حال بررسی موقعیت جغرافیایی",
"runningHint": "نود در حال آزمایش اتصال است — معمولاً تا یک دقیقه طول می‌کشد.",
"changeRoute": "تغییر مسیر",
"rerun": "اجرای دوباره",
"reportAlt": "گزارش GeoCheck برای {{node}}",
"invalidIp": "یک نشانی معتبر IPv4 یا IPv6 وارد کنید",
"invalidInterface": "یک نام رابط شبکه معتبر وارد کنید",
"fullscreen": "تمام‌صفحه",
"exitFullscreen": "خروج از تمام‌صفحه",
"copyJson": "کپی گزارش JSON",
"download": "دانلود SVG",
"zoomIn": "بزرگ‌نمایی",
"zoomOut": "کوچک‌نمایی",
"zoomReset": "بازنشانی بزرگ‌نمایی",
"mode": {
"legend": "مسیر بررسی",
"default": "پیش‌فرض",
"ip": "نشانی IP",
"interface": "رابط شبکه"
},
"hint": {
"default": "نود از مسیر خروجی پیش‌فرض خود استفاده می‌کند.",
"ip": "یکی از نشانی‌های نود را انتخاب کنید یا هر نشانی دلخواه را وارد کنید.",
"interface": "یکی از رابط‌های نود را انتخاب کنید یا هر نامی را وارد کنید."
},
"placeholder": {
"default": "خودکار"
},
"tab": {
"report": "گزارش",
"json": "JSON"
},
"error": {
"title": "بررسی ناموفق بود",
"timeout": "نود به‌موقع پاسخ نداد. دوباره بررسی را اجرا کنید.",
"generic": "انجام بررسی ممکن نشد."
}
},
"overview": {
"bandwidth": "ترافیک ورودی‌ها",
"connections": "اتصالات",

View File

@@ -210,6 +210,9 @@
"orOpenInApp": "Или откройте бота в приложении",
"loginFailed": "Ошибка входа",
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
"deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:",
"loginWithBot": "Войти через бота",
"backToWidget": "Назад к входу через виджет",
"openBotToLogin": "Открыть бота для входа",
"waitingForConfirmation": "Ожидание подтверждения...",
"deepLinkExpired": "Ссылка истекла. Попробуйте снова.",
@@ -755,6 +758,16 @@
"confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание",
"defaultName": "Подписка",
"connectFooter": {
"connect": "Подключить устройство",
"full": "Все слоты заняты",
"connectedDevices": "Подключённые устройства",
"disconnect": "Отключить",
"addSlots": "Добавить слоты",
"limitExplained": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.",
"limitExplainedNoTopup": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.",
"noTopupHint": "На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф."
},
"delete": "Удалить",
"deleteTitle": "Удаление подписки",
"get_config": "Получить конфиг",
@@ -1150,7 +1163,12 @@
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
"contactUs": "Связаться с поддержкой",
"create_ticket": "Создать тикет",
"writeButton": "Написать"
"writeButton": "Написать",
"errors": {
"alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.",
"createFailed": "Не удалось создать обращение. Попробуйте ещё раз.",
"replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз."
}
},
"wheel": {
"title": "Колесо удачи",
@@ -1846,6 +1864,47 @@
},
"usersOnlineCount": "{{count}} онлайн"
},
"geoCheck": {
"title": "GeoCheck",
"start": "Запустить проверку",
"running": "Выполняется проверка геоданных",
"runningHint": "Нода проверяет соединение — обычно занимает до минуты.",
"changeRoute": "Изменить маршрут",
"rerun": "Проверить снова",
"reportAlt": "Отчёт GeoCheck для {{node}}",
"invalidIp": "Введите корректный адрес IPv4 или IPv6",
"invalidInterface": "Введите корректное имя интерфейса",
"fullscreen": "Во весь экран",
"exitFullscreen": "Выйти из полноэкранного режима",
"copyJson": "Скопировать отчёт в JSON",
"download": "Скачать SVG",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"zoomReset": "Сбросить масштаб",
"mode": {
"legend": "Маршрут проверки",
"default": "По умолчанию",
"ip": "IP-адрес",
"interface": "Интерфейс"
},
"hint": {
"default": "Узел будет использовать исходящий маршрут по умолчанию.",
"ip": "Выберите один из IP-адресов узла или введите любой адрес.",
"interface": "Выберите один из интерфейсов узла или введите любое имя."
},
"placeholder": {
"default": "Автоматически"
},
"tab": {
"report": "Отчёт",
"json": "JSON"
},
"error": {
"title": "Проверка не удалась",
"timeout": "Нода не ответила вовремя. Попробуйте запустить проверку ещё раз.",
"generic": "Не удалось выполнить проверку."
}
},
"squads": {
"synced": "Синхронизирован",
"notSynced": "Не синхронизирован",
@@ -2313,7 +2372,11 @@
"sys_maintenance": "Обслуживание",
"sys_backup": "Бэкап",
"sys_version": "Версия",
"sys_logging": "Логи"
"sys_logging": "Логи",
"iface_info": "Правовые документы",
"iface_menu": "Кнопка меню",
"sys_cabinet": "Кабинет",
"sys_other": "Прочие настройки"
},
"presets": {
"standard": "Стандарт",
@@ -2576,7 +2639,7 @@
"Traffic Daily Check Enabled": "Ежедневная проверка",
"Traffic Daily Check Time": "Время проверки",
"Traffic Daily Threshold Gb": "Порог в день (ГБ)",
"Traffic Excluded User Uuids": "Исключённые UUID",
"Traffic Excluded User Ids": "Исключённые ID пользователей панели",
"Traffic Fast Check Enabled": "Быстрая проверка",
"Traffic Fast Check Interval Minutes": "Интервал (мин)",
"Traffic Fast Check Threshold Gb": "Порог (ГБ)",
@@ -2769,6 +2832,9 @@
"telegramOidcClientSecret": "Client Secret"
},
"categories": {
"CABINET": "Кабинет",
"INFO_PAGES": "Правовые документы и FAQ",
"MENU": "Кнопка меню",
"PAYMENT": "Платежи",
"PAYMENT_VERIFICATION": "Верификация платежей",
"YOOKASSA": "YooKassa",
@@ -3687,7 +3753,10 @@
"save": "Сохранить",
"defaultTrialTariff": "Тариф триала по умолчанию",
"selectTariff": "Выберите тариф",
"tariff": "Тариф"
"tariff": "Тариф",
"includeTraffic": "Трафик",
"trafficAmount": "Объём трафика",
"gb": "ГБ"
},
"stats": {
"title": "Статистика промокода",
@@ -3763,7 +3832,8 @@
"daysRequired": "Количество дней должно быть больше 0",
"groupRequired": "Выберите группу скидок",
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
"discountHoursRequired": "Укажите время действия скидки в часах"
"discountHoursRequired": "Укажите время действия скидки в часах",
"trafficRequired": "Объём трафика должен быть больше 0"
}
},
"promoGroups": {
@@ -3846,7 +3916,8 @@
"byDate": "По дате",
"byBalance": "По балансу",
"byActivity": "По активности",
"bySpent": "По расходам"
"bySpent": "По расходам",
"byExpiry": "По истечению подписки"
},
"pagination": {
"showing": "Показано {{from}}-{{to}} из {{total}}"
@@ -4036,7 +4107,11 @@
"sbpCancelled": "Автоплатёж по СБП отключён",
"sbpStatus_PENDING": "Ожидает подтверждения",
"sbpStatus_ACTIVE": "Активен",
"sbpStatus_PAST_DUE": "Платёж не прошёл"
"sbpStatus_PAST_DUE": "Платёж не прошёл",
"deleteTitle": "Удаление подписки",
"deleteHint": "Подписка и её устройства будут удалены безвозвратно",
"deleteButton": "Удалить подписку",
"deleted": "Подписка удалена"
},
"balance": {
"current": "Текущий баланс",

View File

@@ -199,6 +199,9 @@
"orOpenInApp": "或在应用中打开机器人",
"loginFailed": "登录失败",
"telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录",
"deepLinkIntro": "直接在机器人中确认登录 — 无需手机号:",
"loginWithBot": "通过机器人登录",
"backToWidget": "返回小部件登录",
"openBotToLogin": "打开机器人登录",
"waitingForConfirmation": "等待确认...",
"deepLinkExpired": "链接已过期,请重试。",
@@ -641,6 +644,16 @@
},
"dailyAutoCharge": "每日自动扣费",
"defaultName": "订阅",
"connectFooter": {
"connect": "连接设备",
"full": "所有名额已占满",
"connectedDevices": "已连接设备",
"disconnect": "断开",
"addSlots": "增加名额",
"limitExplained": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请释放一个名额或增加名额。",
"limitExplainedNoTopup": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请先释放一个名额。",
"noTopupHint": "试用订阅无法购买名额 — 请释放已占用的名额或改用付费套餐。"
},
"delete": "删除",
"deleteTitle": "删除订阅",
"get_config": "获取配置",
@@ -1004,7 +1017,12 @@
"ticketsDisabled": "工单已禁用",
"useExternalLink": "请使用外部链接获取支持",
"create_ticket": "创建工单",
"writeButton": "发消息"
"writeButton": "发消息",
"errors": {
"alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。",
"createFailed": "创建工单失败,请重试。",
"replyFailed": "发送回复失败,请重试。"
}
},
"wheel": {
"title": "幸运转盘",
@@ -2036,9 +2054,16 @@
"sys_maintenance": "维护",
"sys_backup": "备份",
"sys_version": "版本",
"sys_logging": "日志"
"sys_logging": "日志",
"iface_info": "法律文件",
"iface_menu": "菜单按钮",
"sys_cabinet": "用户中心",
"sys_other": "其他设置"
},
"categories": {
"CABINET": "用户中心",
"INFO_PAGES": "法律文件与 FAQ",
"MENU": "菜单按钮",
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
@@ -2802,7 +2827,10 @@
"save": "保存",
"defaultTrialTariff": "默认试用套餐",
"selectTariff": "选择套餐",
"tariff": "套餐"
"tariff": "套餐",
"includeTraffic": "流量",
"trafficAmount": "流量额度",
"gb": "GB"
},
"stats": {
"title": "促销码统计",
@@ -2874,7 +2902,8 @@
"daysRequired": "天数必须大于0",
"groupRequired": "请选择折扣组",
"discountPercentInvalid": "折扣百分比必须在1到100之间",
"discountHoursRequired": "请指定折扣有效期(小时)"
"discountHoursRequired": "请指定折扣有效期(小时)",
"trafficRequired": "流量额度必须大于 0"
}
},
"promoGroups": {
@@ -2955,7 +2984,8 @@
"byDate": "按日期",
"byBalance": "按余额",
"byActivity": "按活跃度",
"bySpent": "按消费"
"bySpent": "按消费",
"byExpiry": "按到期时间"
},
"pagination": {
"showing": "显示 {{from}}-{{to}},共 {{total}}"
@@ -3097,7 +3127,11 @@
"sbpCancelled": "SBP 自动扣款已关闭",
"sbpStatus_PENDING": "待确认",
"sbpStatus_ACTIVE": "已启用",
"sbpStatus_PAST_DUE": "扣款失败"
"sbpStatus_PAST_DUE": "扣款失败",
"deleteTitle": "删除订阅",
"deleteHint": "订阅及其设备将被永久删除",
"deleteButton": "删除订阅",
"deleted": "订阅已删除"
},
"balance": {
"current": "当前余额",
@@ -3448,6 +3482,47 @@
},
"usersOnlineCount": "{{count}} 在线"
},
"geoCheck": {
"title": "GeoCheck",
"start": "开始检测",
"running": "正在检测地理位置",
"runningHint": "节点正在测试连接,通常需要一分钟以内。",
"changeRoute": "更改路由",
"rerun": "重新检测",
"reportAlt": "{{node}} 的 GeoCheck 报告",
"invalidIp": "请输入有效的 IPv4 或 IPv6 地址",
"invalidInterface": "请输入有效的网卡名称",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"copyJson": "复制 JSON 报告",
"download": "下载 SVG",
"zoomIn": "放大",
"zoomOut": "缩小",
"zoomReset": "重置缩放",
"mode": {
"legend": "检测路由",
"default": "默认",
"ip": "IP 地址",
"interface": "网络接口"
},
"hint": {
"default": "节点将使用默认出站路由。",
"ip": "选择节点的某个 IP 地址,或输入任意地址。",
"interface": "选择节点的某个网络接口,或输入任意名称。"
},
"placeholder": {
"default": "自动"
},
"tab": {
"report": "报告",
"json": "JSON"
},
"error": {
"title": "检测失败",
"timeout": "节点未及时响应。请重新运行检测。",
"generic": "无法完成检测。"
}
},
"overview": {
"bandwidth": "各 inbound 流量",
"connections": "连接数",

View File

@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
init,
restoreInitData,
retrieveRawInitData,
mountMiniApp,
miniAppReady,
mountViewport,
@@ -21,6 +20,7 @@ import {
} from '@telegram-apps/sdk-react';
import { clearStaleSessionIfNeeded } from './utils/token';
import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard';
import { getTelegramInitData } from './utils/telegramInitData';
import { useAuthStore } from './store/auth';
import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary';
@@ -38,7 +38,8 @@ installEncodingSurrogateGuard();
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
// @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally.
// Without this, init() throws LaunchParamsRetrieveError on affected devices.
// Without this, init() and any launch-params retrieval below throw
// LaunchParamsRetrieveError on affected devices.
// See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683
if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -62,7 +63,7 @@ if (isTelegramEnv && !alreadyInitialized) {
init();
restoreInitData();
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
clearStaleSessionIfNeeded(getTelegramInitData());
// Adopt the user's Telegram client language on first run (no explicit choice yet).
applyTelegramLanguage();

View File

@@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() {
const [includeBalance, setIncludeBalance] = useState(true);
const [includeDays, setIncludeDays] = useState(false);
const [includeGroup, setIncludeGroup] = useState(false);
const [includeTraffic, setIncludeTraffic] = useState(false);
const [trafficGb, setTrafficGb] = useState<number | ''>(0);
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
const [maxUses, setMaxUses] = useState<number | ''>(1);
@@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() {
setMode(data.type);
} else {
setMode('bonus_set');
setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days');
setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days');
// Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика
// balance_and_days перестал означать «есть и баланс, и дни»: этот тип
// теперь у любого набора с трафиком, в том числе с нулевым балансом и
// нулём дней. По типу такой код открывался бы с двумя чужими галочками
// на нулях, валидация требовала бы «больше 0», и Сохранить не работало
// бы, пока админ их не снимет, — а подсказка толкает вместо этого
// вписать сумму и молча добавить коду бонус, которого в нём не было.
setIncludeBalance(data.type === 'balance' || (data.balance_bonus_rubles || 0) > 0);
setIncludeDays(data.type === 'subscription_days' || (data.subscription_days || 0) > 0);
// Промогруппа комбинируется с любым составом (bэкенд назначает её
// независимо от типа), поэтому чекбокс — по факту наличия группы
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
@@ -99,6 +108,8 @@ export default function AdminPromocodeCreate() {
setBalanceBonusRubles(data.balance_bonus_rubles || 0);
}
setSubscriptionDays(data.subscription_days || 0);
setTrafficGb(data.traffic_gb || 0);
setIncludeTraffic((data.traffic_gb || 0) > 0);
setMaxUses(data.max_uses || 1);
setIsActive(data.is_active ?? true);
setFirstPurchaseOnly(data.first_purchase_only || false);
@@ -133,7 +144,7 @@ export default function AdminPromocodeCreate() {
// едет через promo_group_id при любом типе (бэкенд применяет её независимо).
const derivedType: PromoCodeType =
mode === 'bonus_set'
? includeBalance && includeDays
? includeTraffic || (includeBalance && includeDays)
? 'balance_and_days'
: includeBalance
? 'balance'
@@ -148,6 +159,7 @@ export default function AdminPromocodeCreate() {
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
const maxUsesValue = maxUses === '' ? 0 : maxUses;
const trafficValue = trafficGb === '' ? 0 : trafficGb;
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
code: code.trim().toUpperCase(),
@@ -164,6 +176,7 @@ export default function AdminPromocodeCreate() {
(mode === 'bonus_set' && includeDays)
? daysValue
: 0,
traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0,
max_uses: maxUsesValue,
is_active: isActive,
first_purchase_only: firstPurchaseOnly,
@@ -197,7 +210,7 @@ export default function AdminPromocodeCreate() {
validationErrors.push('codeRequired');
}
if (mode === 'bonus_set') {
if (!includeBalance && !includeDays && !includeGroup) {
if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) {
validationErrors.push('bonusSetEmpty');
}
if (includeBalance && balanceValue <= 0) {
@@ -209,6 +222,9 @@ export default function AdminPromocodeCreate() {
if (includeGroup && !promoGroupId) {
validationErrors.push('groupRequired');
}
if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) {
validationErrors.push('trafficRequired');
}
}
if (mode === 'trial_subscription' && daysValue <= 0) {
validationErrors.push('daysRequired');
@@ -308,6 +324,7 @@ export default function AdminPromocodeCreate() {
[
['includeBalance', includeBalance, setIncludeBalance] as const,
['includeDays', includeDays, setIncludeDays] as const,
['includeTraffic', includeTraffic, setIncludeTraffic] as const,
['includePromoGroup', includeGroup, setIncludeGroup] as const,
] as const
).map(([key, checked, setChecked]) => (
@@ -361,6 +378,28 @@ export default function AdminPromocodeCreate() {
</div>
)}
{mode === 'bonus_set' && includeTraffic && (
<div>
<label htmlFor="pc-traffic-gb" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.trafficAmount')}
<span className="text-error-400">*</span>
</label>
<div className="flex items-center gap-2">
<input
id="pc-traffic-gb"
type="number"
value={trafficGb}
onChange={createNumberInputHandler(setTrafficGb, 0)}
className="input w-32"
min={0}
step={1}
placeholder="0"
/>
<span className="text-dark-400">{t('admin.promocodes.form.gb')}</span>
</div>
</div>
)}
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
<div>
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -196,16 +196,27 @@ export default function AdminPromocodes() {
</div>
{/* Info line */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{/* Составляющие показываем по значению, а не по типу:
balance_and_days теперь стоит и у набора, где баланса
или дней нет вовсе, — иначе в списке висело бы «+0 ₽
+0 дн.» у кода, который на самом деле раздаёт трафик. */}
{(promo.type === 'balance' || promo.type === 'balance_and_days') &&
promo.balance_bonus_rubles > 0 && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{(promo.type === 'subscription_days' ||
promo.type === 'trial_subscription' ||
promo.type === 'balance_and_days') && (
promo.type === 'balance_and_days') &&
promo.subscription_days > 0 && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
</span>
)}
{promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
+{promo.traffic_gb} {t('admin.promocodes.form.gb')}
</span>
)}
{promo.type === 'discount' && (

View File

@@ -4,16 +4,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
adminRemnawaveApi,
NodeInfo,
NodeRealtimeStats,
SquadWithLocalInfo,
SystemStatsResponse,
AutoSyncStatus,
RecapResponse,
DevicesStatsResponse,
TopConsumersResponse,
HealthResponse,
SubscriptionRequestStatsResponse,
type NodeInfo,
type NodeRealtimeStats,
type SquadWithLocalInfo,
type SystemStatsResponse,
type AutoSyncStatus,
type RecapResponse,
type DevicesStatsResponse,
type TopConsumersResponse,
type HealthResponse,
type SubscriptionRequestStatsResponse,
} from '../api/adminRemnawave';
import { usePlatform } from '../platform/hooks/usePlatform';
import { formatUptime } from '../utils/format';
@@ -54,14 +54,17 @@ import {
SubscriptionIcon,
BackIcon,
ChevronRightIcon,
GeoCheckIcon,
} from '../components/icons';
import { GeoCheckModal } from '../components/admin/remnawave/GeoCheckModal';
import { supportsGeoCheck } from '../utils/nodeVersion';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return parseFloat((bytes / k ** i).toFixed(2)) + ' ' + sizes[i];
};
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback
@@ -149,6 +152,11 @@ interface NodeCardProps {
function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [geoCheckOpen, setGeoCheckOpen] = useState(false);
// GeoCheck умеет только узел 3.3.0+; на старом узле кнопку не показываем,
// чтобы админ не упирался в ошибку панели.
const canGeoCheck = supportsGeoCheck(node.versions);
const isUp = node.is_connected && node.is_node_online && !node.is_disabled;
const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400';
@@ -193,245 +201,266 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
? 'text-warning-400'
: 'text-dark-400';
// Модалка — сосед кликабельного блока, а не его потомок: портал уносит её
// в document.body только по DOM, а события React прогоняет по дереву
// компонентов, и клики внутри неё всплывали бы в onClick карточки.
return (
<div
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
hasBreakdown ? 'cursor-pointer' : ''
}`}
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
>
{/* Identity + actions */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
title={statusText}
/>
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
<UsersIcon className="h-3 w-3" />
{node.users_online ?? 0}
</span>
<span className="shrink-0 text-base leading-none">
{getCountryFlag(node.country_code)}
</span>
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
{(providerLabel || providerFavicon) && (
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
{providerFavicon && (
<img
src={providerFavicon}
alt=""
className="h-3 w-3 shrink-0 rounded-[2px]"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
{providerLabel && <span className="truncate">{providerLabel}</span>}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
onClick={(e) => {
e.stopPropagation();
onAction(node.uuid, 'restart');
}}
disabled={isLoading || node.is_disabled}
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
title={t('admin.remnawave.nodes.restart', 'Restart')}
>
<ArrowPathIcon className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
}}
disabled={isLoading}
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
node.is_disabled
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
}`}
title={
node.is_disabled
? t('admin.remnawave.nodes.enable', 'Enable')
: t('admin.remnawave.nodes.disable', 'Disable')
}
>
{node.is_disabled ? (
<PlayIcon className="h-3.5 w-3.5" />
) : (
<StopIcon className="h-3.5 w-3.5" />
)}
</button>
{hasBreakdown && (
<ChevronRightIcon
className={`h-4 w-4 text-dark-500 transition-transform ${
expanded ? 'rotate-90' : ''
}`}
<>
<div
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
hasBreakdown ? 'cursor-pointer' : ''
}`}
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
>
{/* Identity + actions */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
title={statusText}
/>
)}
</div>
</div>
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
<UsersIcon className="h-3 w-3" />
{node.users_online ?? 0}
</span>
<span className="shrink-0 text-base leading-none">
{getCountryFlag(node.country_code)}
</span>
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
{(providerLabel || providerFavicon) && (
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
{providerFavicon && (
<img
src={providerFavicon}
alt=""
className="h-3 w-3 shrink-0 rounded-[2px]"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
{providerLabel && <span className="truncate">{providerLabel}</span>}
</span>
)}
</div>
{/* Address + traffic + uptime */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
<GlobeIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{node.address}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-dark-300">{formatBytes(used)}</span>
{trafficPct !== null && (
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-accent-500"
style={{ width: `${trafficPct}%` }}
<div className="flex shrink-0 items-center gap-1.5">
{canGeoCheck && (
<button
onClick={(e) => {
e.stopPropagation();
setGeoCheckOpen(true);
}}
disabled={node.is_disabled || !node.is_connected}
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
title={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
aria-label={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
>
<GeoCheckIcon className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onAction(node.uuid, 'restart');
}}
disabled={isLoading || node.is_disabled}
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
title={t('admin.remnawave.nodes.restart', 'Restart')}
>
<ArrowPathIcon className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
}}
disabled={isLoading}
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
node.is_disabled
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
}`}
title={
node.is_disabled
? t('admin.remnawave.nodes.enable', 'Enable')
: t('admin.remnawave.nodes.disable', 'Disable')
}
>
{node.is_disabled ? (
<PlayIcon className="h-3.5 w-3.5" />
) : (
<StopIcon className="h-3.5 w-3.5" />
)}
</button>
{hasBreakdown && (
<ChevronRightIcon
className={`h-4 w-4 text-dark-500 transition-transform ${
expanded ? 'rotate-90' : ''
}`}
/>
)}
</div>
</div>
{/* Address + traffic + uptime */}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
<GlobeIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{node.address}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-dark-300">{formatBytes(used)}</span>
{trafficPct !== null && (
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-accent-500"
style={{ width: `${trafficPct}%` }}
/>
</span>
)}
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
</span>
{node.xray_uptime > 0 && (
<span className="flex items-center gap-1 text-dark-500">
<StatUptimeIcon className="h-3 w-3" />
{formatUptime(node.xray_uptime)}
</span>
)}
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
</span>
{node.xray_uptime > 0 && (
<span className="flex items-center gap-1 text-dark-500">
<StatUptimeIcon className="h-3 w-3" />
{formatUptime(node.xray_uptime)}
</span>
</div>
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
desktop (sm+): the original single wrap row, wide enough not to jump. */}
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
<>
{/* Mobile: processor · traffic · versions */}
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
{(loadAvg || ramPct !== null) && (
<div className="flex items-center gap-3">
{loadAvg && (
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
{loadAvg}
</span>
)}
{ramPct !== null && (
<span className="flex items-center gap-1.5" title="RAM">
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
<span className={ramColorClass}>{ramPct}%</span>
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-dark-400"
style={{ width: `${ramPct}%` }}
/>
</span>
</span>
)}
</div>
)}
{(rx > 0 || tx > 0) && (
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
{formatSpeed(rx)}
</span>
<span className="flex items-center gap-1">
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
{formatSpeed(tx)}
</span>
</div>
)}
{(node.versions?.node || node.versions?.xray) && (
<div className="flex items-center gap-3 text-dark-600">
{node.versions?.node && (
<span className="flex items-center gap-1" title="remnanode">
<RemnawaveIcon className="h-3 w-3 shrink-0" />
{node.versions.node}
</span>
)}
{node.versions?.xray && (
<span className="flex items-center gap-1" title="xray core">
<XrayIcon className="h-3 w-3 shrink-0" />
{node.versions.xray}
</span>
)}
</div>
)}
</div>
{/* Desktop: single wrap row (original) */}
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
{ramPct !== null && (
<span className="flex items-center gap-1.5" title="RAM">
<MemoryIcon className="h-3 w-3 text-dark-500" />
<span className={ramColorClass}>{ramPct}%</span>
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-dark-400"
style={{ width: `${ramPct}%` }}
/>
</span>
</span>
)}
{loadAvg && (
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
<CpuIcon className="h-3 w-3 text-dark-500" />
{loadAvg}
</span>
)}
<span className="flex items-center gap-2">
<span className="flex items-center gap-0.5">
<DownloadIcon className="h-3 w-3 text-success-400/70" />
{formatSpeed(rx)}
</span>
<span className="flex items-center gap-0.5">
<UploadIcon className="h-3 w-3 text-accent-400/70" />
{formatSpeed(tx)}
</span>
</span>
{(node.versions?.node || node.versions?.xray) && (
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
{node.versions?.node && (
<span className="flex items-center gap-1" title="remnanode">
<RemnawaveIcon className="h-3 w-3" />
{node.versions.node}
</span>
)}
{node.versions?.xray && (
<span className="flex items-center gap-1" title="xray core">
<XrayIcon className="h-3 w-3" />
{node.versions.xray}
</span>
)}
</span>
)}
</div>
</>
)}
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
{expanded && hasBreakdown && (
<div
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
onClick={(e) => e.stopPropagation()}
>
{inbounds.length > 0 && (
<NodeTrafficBreakdown
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
items={inbounds}
/>
)}
{outbounds.length > 0 && (
<NodeTrafficBreakdown
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
items={outbounds}
/>
)}
</div>
)}
</div>
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
desktop (sm+): the original single wrap row, wide enough not to jump. */}
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
<>
{/* Mobile: processor · traffic · versions */}
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
{(loadAvg || ramPct !== null) && (
<div className="flex items-center gap-3">
{loadAvg && (
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
{loadAvg}
</span>
)}
{ramPct !== null && (
<span className="flex items-center gap-1.5" title="RAM">
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
<span className={ramColorClass}>{ramPct}%</span>
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-dark-400"
style={{ width: `${ramPct}%` }}
/>
</span>
</span>
)}
</div>
)}
{(rx > 0 || tx > 0) && (
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
{formatSpeed(rx)}
</span>
<span className="flex items-center gap-1">
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
{formatSpeed(tx)}
</span>
</div>
)}
{(node.versions?.node || node.versions?.xray) && (
<div className="flex items-center gap-3 text-dark-600">
{node.versions?.node && (
<span className="flex items-center gap-1" title="remnanode">
<RemnawaveIcon className="h-3 w-3 shrink-0" />
{node.versions.node}
</span>
)}
{node.versions?.xray && (
<span className="flex items-center gap-1" title="xray core">
<XrayIcon className="h-3 w-3 shrink-0" />
{node.versions.xray}
</span>
)}
</div>
)}
</div>
{/* Desktop: single wrap row (original) */}
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
{ramPct !== null && (
<span className="flex items-center gap-1.5" title="RAM">
<MemoryIcon className="h-3 w-3 text-dark-500" />
<span className={ramColorClass}>{ramPct}%</span>
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
<span
className="block h-full rounded-full bg-dark-400"
style={{ width: `${ramPct}%` }}
/>
</span>
</span>
)}
{loadAvg && (
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
<CpuIcon className="h-3 w-3 text-dark-500" />
{loadAvg}
</span>
)}
<span className="flex items-center gap-2">
<span className="flex items-center gap-0.5">
<DownloadIcon className="h-3 w-3 text-success-400/70" />
{formatSpeed(rx)}
</span>
<span className="flex items-center gap-0.5">
<UploadIcon className="h-3 w-3 text-accent-400/70" />
{formatSpeed(tx)}
</span>
</span>
{(node.versions?.node || node.versions?.xray) && (
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
{node.versions?.node && (
<span className="flex items-center gap-1" title="remnanode">
<RemnawaveIcon className="h-3 w-3" />
{node.versions.node}
</span>
)}
{node.versions?.xray && (
<span className="flex items-center gap-1" title="xray core">
<XrayIcon className="h-3 w-3" />
{node.versions.xray}
</span>
)}
</span>
)}
</div>
</>
)}
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
{expanded && hasBreakdown && (
<div
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
onClick={(e) => e.stopPropagation()}
>
{inbounds.length > 0 && (
<NodeTrafficBreakdown
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
items={inbounds}
/>
)}
{outbounds.length > 0 && (
<NodeTrafficBreakdown
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
items={outbounds}
/>
)}
</div>
)}
</div>
{geoCheckOpen && <GeoCheckModal node={node} onClose={() => setGeoCheckOpen(false)} />}
</>
);
}

View File

@@ -2,10 +2,16 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin';
import {
OTHER_CATEGORIES,
SETTINGS_TREE,
findTreeLocation,
formatSettingKey,
getMappedCategoryKeys,
} from '../components/admin';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
@@ -75,10 +81,18 @@ export default function AdminSettings() {
if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return [];
const categoryKeys = activeTreeInfo.child.categories;
// Пункт-сборник показывает всё, чему не нашлось места в дереве: категории на
// бэкенде выводятся из имени ключа, поэтому новая настройка легко создаёт
// категорию, которой здесь нет — раньше такие настройки просто исчезали.
const isOtherSection = categoryKeys.includes(OTHER_CATEGORIES);
const mappedCategories = isOtherSection ? getMappedCategoryKeys() : null;
const categoryMap = new Map<string, SettingDefinition[]>();
for (const setting of allSettings) {
if (categoryKeys.includes(setting.category.key)) {
const belongsHere = mappedCategories
? !mappedCategories.has(setting.category.key)
: categoryKeys.includes(setting.category.key);
if (belongsHere) {
// Hide tariff-dependent settings when not in tariffs mode
if (!isTariffsMode && TARIFF_MODE_SETTINGS.includes(setting.key)) continue;

View File

@@ -551,7 +551,7 @@ export default function AdminTickets() {
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
className="whitespace-pre-wrap break-words text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}

View File

@@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
import { InfoTab } from '../components/admin/userDetail/InfoTab';
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
import { getApiErrorMessage } from '../utils/api-error';
import { toNumber } from '../utils/inputHelpers';
import { usePermissionStore } from '../store/permissions';
@@ -662,6 +663,28 @@ export default function AdminUserDetail() {
}
};
const handleDeleteSubscription = async () => {
if (!userId || !selectedSub) return;
setActionLoading(true);
try {
// Активную платную подписку сервер по умолчанию бережёт — админ уже
// подтвердил намерение кнопкой, поэтому просим удалить именно её.
const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial;
await adminUsersApi.deleteSubscription(userId, selectedSub.id, force);
notify.success(t('admin.users.detail.subscription.deleted'), t('common.success'));
setSubscriptionDetailView(false);
await loadUser();
} catch (err) {
// Отказы тут осмысленные и действенные: открытый временный доступ
// (409, «сначала заверши или восстанови grace»), активная платная без
// force (409), подписки нет (404). Общее «Ошибка» оставило бы админа
// гадать, почему кнопка не сработала, — показываем текст сервера.
notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleDisableUser = async () => {
if (!userId) return;
setActionLoading(true);
@@ -874,6 +897,7 @@ export default function AdminUserDetail() {
userSubscriptions={userSubscriptions}
selectedSub={selectedSub}
onCancelSbpRecurring={handleCancelSbpRecurring}
onDeleteSubscription={handleDeleteSubscription}
activeSubscriptionId={activeSubscriptionId}
onActiveSubscriptionChange={setActiveSubscriptionId}
subscriptionDetailView={subscriptionDetailView}

View File

@@ -288,6 +288,7 @@ export default function AdminUsers() {
<option value="balance">{t('admin.users.filters.byBalance')}</option>
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
<option value="subscription_end_date">{t('admin.users.filters.byExpiry')}</option>
</select>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
@@ -20,6 +20,7 @@ import { giftApi } from '../api/gift';
import { promoApi } from '../api/promo';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
import { DeviceLimitSheet } from '../components/subscription/DeviceLimitSheet';
import { API } from '../config/constants';
import { ChevronRightIcon, StarIcon } from '@/components/icons';
@@ -79,6 +80,35 @@ export default function Dashboard() {
staleTime: API.BALANCE_STALE_TIME_MS,
});
// Плитка «Подключить устройство» на главной живёт в МУЛЬТИТАРИФНОЙ ветке, а
// запрос выше там выключен: он привязан к одиночной подписке (`subscription`
// в мультитарифе всегда null). Без отдельного запроса счётчик плитки всегда
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
// есть ровно то, ради чего плитку и добавили, не работало бы.
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
// Карточки подписок на главной показывают, сколько устройств подключено, и
// дают подключить ещё. Число устройств живёт в панели, поэтому запрос идёт
// на каждую показанную подписку; ключ ['devices', id] тот же, что на
// странице подписки, так что кэш общий и переход туда не стоит сети.
const visibleSubscriptions = useMemo(
() => multiSubData?.subscriptions?.slice(0, 3) ?? [],
[multiSubData],
);
const deviceQueries = useQueries({
queries: visibleSubscriptions.map((sub) => ({
queryKey: ['devices', sub.id],
queryFn: () => subscriptionApi.getDevices(sub.id),
staleTime: API.BALANCE_STALE_TIME_MS,
})),
});
// Подписка, у которой разбираем исчерпанный лимит устройств.
const [deviceLimitSubId, setDeviceLimitSubId] = useState<number | null>(null);
const deviceLimitSub = visibleSubscriptions.find((s) => s.id === deviceLimitSubId) ?? null;
const deviceLimitDevices =
deviceQueries[visibleSubscriptions.findIndex((s) => s.id === deviceLimitSubId)]?.data;
const { data: referralInfo, isLoading: refLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
@@ -294,11 +324,16 @@ export default function Dashboard() {
{t('dashboard.manageAll', 'Управление')}
</Link>
</div>
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
{visibleSubscriptions.map((sub, index) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
connect={{
connectedDevices: deviceQueries[index]?.data?.total,
onConnect: () => navigate(`/connection?sub=${sub.id}`),
onManage: () => setDeviceLimitSubId(sub.id),
}}
/>
))}
{multiSubData.subscriptions.length > 3 && (
@@ -426,6 +461,22 @@ export default function Dashboard() {
onSkip={handleOnboardingComplete}
/>
)}
{deviceLimitSub && (
<DeviceLimitSheet
isOpen
onClose={() => setDeviceLimitSubId(null)}
subscriptionId={deviceLimitSub.id}
subscriptionName={deviceLimitSub.tariff_name || t('subscription.defaultName', 'Подписка')}
deviceLimit={deviceLimitSub.device_limit}
isTrial={deviceLimitSub.is_trial}
devices={deviceLimitDevices?.devices ?? []}
onOpenSubscription={() => {
setDeviceLimitSubId(null);
navigate(`/subscriptions/${deviceLimitSub.id}`);
}}
/>
)}
</div>
);
}

View File

@@ -29,6 +29,8 @@ import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/compone
import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
import { getPendingCampaignSlug } from '../utils/campaign';
import { readContactPrefill, stripContactFromUrl } from '../utils/contactPrefill';
import { formatPrice } from '../utils/format';
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
import { useCurrency } from '../hooks/useCurrency';
@@ -868,13 +870,12 @@ export default function QuickPurchase() {
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const contactKey = `lp_contact_${slug ?? ''}`;
const [contactValue, setContactValue] = useState(() => {
try {
return localStorage.getItem(contactKey) || '';
} catch {
return '';
}
});
const [contactValue, setContactValue] = useState(() => readContactPrefill(contactKey));
// Контакт уже в состоянии — вычищаем его из адресной строки, чтобы личный
// email не уехал в Метрику, Referer и историю браузера.
useEffect(() => {
stripContactFromUrl();
}, []);
const [isGift, setIsGift] = useState(false);
const [giftRecipient, setGiftRecipient] = useState('');
const [giftMessage, setGiftMessage] = useState('');
@@ -1085,6 +1086,12 @@ export default function QuickPurchase() {
const subid = sessionStorage.getItem('landing_subid');
if (subid) (data as unknown as Record<string, unknown>).subid = subid;
// Слаг рекламной кампании захватил captureCampaignFromUrl() при заходе по
// рекламной ссылке. Читаем БЕЗ потребления: гость может позже войти в
// кабинет, и там привязка должна остаться возможной.
const campaignSlug = getPendingCampaignSlug();
if (campaignSlug) data.campaign_slug = campaignSlug;
// Fire landing-specific click goal
if (config?.analytics_click_enabled && config?.analytics_click_goal) {
try {

View File

@@ -9,6 +9,8 @@ import { infoApi } from '../api/info';
import { useAuthStore } from '../store/auth';
import { logger } from '../utils/logger';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import { getApiErrorMessage } from '../utils/api-error';
import { isOpenTicketConflict } from '../utils/ticketErrors';
import type { SupportConfig, TicketDetail } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -55,7 +57,10 @@ export default function Support() {
const [newTitle, setNewTitle] = useState('');
const [newMessage, setNewMessage] = useState('');
const [replyMessage, setReplyMessage] = useState('');
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
// Ошибка активной формы: и клиентский rate-limit, и отказ бэка (409 «уже есть
// открытый тикет», 403 «поддержка выключена/пользователь заблокирован» и т.п.).
// Формы create и reply взаимоисключающие, поэтому состояние одно на обе.
const [formError, setFormError] = useState<string | null>(null);
// Media attachment states (multi-upload, up to 10)
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
@@ -154,11 +159,26 @@ export default function Support() {
onSuccess: (ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setShowCreateForm(false);
setFormError(null);
setNewTitle('');
setNewMessage('');
clearCreateAttachments();
setSelectedTicket(ticket);
},
onError: (error) => {
// Без этого отказ бэка (чаще всего 409 «уже есть открытый тикет») уходил
// в никуда: форма просто оставалась на месте, и пользователь жал «Отправить»
// снова и снова, не понимая, почему обращение не создаётся.
log.error('Ticket creation failed', error);
if (isOpenTicketConflict(error)) {
setFormError(t('support.errors.alreadyOpenTicket'));
// Открытый тикет мог появиться в другой сессии (бот, второе устройство) —
// подтягиваем список, чтобы пользователю было куда перейти.
queryClient.invalidateQueries({ queryKey: ['tickets'] });
return;
}
setFormError(getApiErrorMessage(error, t('support.errors.createFailed')));
},
});
const replyMutation = useMutation({
@@ -176,9 +196,16 @@ export default function Support() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
setFormError(null);
setReplyMessage('');
clearReplyAttachments();
},
onError: (error) => {
// Ответ в тикет молчал ровно так же: 403 (блокировка в поддержке) и 400
// (тикет уже закрыт) выглядели как «кнопка не работает».
log.error('Ticket reply failed', error);
setFormError(getApiErrorMessage(error, t('support.errors.replyFailed')));
},
});
const getStatusBadge = (status: string) => {
@@ -321,6 +348,7 @@ export default function Support() {
onClick={() => {
setShowCreateForm(true);
setSelectedTicket(null);
setFormError(null);
clearCreateAttachments();
}}
>
@@ -374,6 +402,7 @@ export default function Support() {
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail);
setShowCreateForm(false);
setFormError(null);
clearReplyAttachments();
}}
className={`w-full rounded-bento border p-4 text-left transition-all ${
@@ -414,11 +443,11 @@ export default function Support() {
<form
onSubmit={(e) => {
e.preventDefault();
setRateLimitError(null);
setFormError(null);
// Rate limit: max 3 tickets per 60 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
return;
}
createMutation.mutate();
@@ -495,9 +524,9 @@ export default function Support() {
)}
</div>
{rateLimitError && (
{formError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{rateLimitError}
{formError}
</div>
)}
@@ -515,6 +544,7 @@ export default function Support() {
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setFormError(null);
clearCreateAttachments();
}}
>
@@ -570,7 +600,7 @@ export default function Support() {
</div>
{msg.message_text && (
<div
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
className="whitespace-pre-wrap break-words text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
@@ -589,11 +619,11 @@ export default function Support() {
<form
onSubmit={(e) => {
e.preventDefault();
setRateLimitError(null);
setFormError(null);
// Rate limit: max 5 replies per 30 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
return;
}
replyMutation.mutate();
@@ -662,9 +692,9 @@ export default function Support() {
<SendIcon className="h-4 w-4" />
</Button>
</div>
{rateLimitError && (
{formError && (
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
{rateLimitError}
{formError}
</div>
)}
</div>

View File

@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { consumeCampaignSlug, getPendingCampaignSlug } from './campaign';
const CAMPAIGN_KEY = 'campaign_slug';
const CAMPAIGN_TTL_KEY = 'campaign_slug_ttl';
const HOUR = 60 * 60 * 1000;
function fakeLocalStorage(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
key: (index: number) => [...store.keys()][index] ?? null,
removeItem: (key: string) => void store.delete(key),
setItem: (key: string, value: string) => void store.set(key, value),
} as Storage;
}
function storeSlug(slug: string, expiresInMs = 24 * HOUR): void {
localStorage.setItem(CAMPAIGN_KEY, slug);
localStorage.setItem(CAMPAIGN_TTL_KEY, String(Date.now() + expiresInMs));
}
beforeEach(() => {
vi.stubGlobal('localStorage', fakeLocalStorage());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('getPendingCampaignSlug', () => {
it('returns the stored slug', () => {
storeSlug('summer_sale');
expect(getPendingCampaignSlug()).toBe('summer_sale');
});
// Гостевая покупка на лендинге читает слаг этим геттером. Если он начнёт
// очищать хранилище, тот же клиент, зайдя потом в кабинет, потеряет
// привязку к кампании — auth-флоу уже ничего не найдёт.
it('does not consume the slug', () => {
storeSlug('summer_sale');
getPendingCampaignSlug();
expect(getPendingCampaignSlug()).toBe('summer_sale');
expect(localStorage.getItem(CAMPAIGN_KEY)).toBe('summer_sale');
});
it('drops an expired slug', () => {
storeSlug('summer_sale', -HOUR);
expect(getPendingCampaignSlug()).toBeNull();
expect(localStorage.getItem(CAMPAIGN_TTL_KEY)).toBeNull();
});
it('returns null when nothing was captured', () => {
expect(getPendingCampaignSlug()).toBeNull();
});
});
describe('consumeCampaignSlug', () => {
it('clears the slug after reading it once', () => {
storeSlug('summer_sale');
expect(consumeCampaignSlug()).toBe('summer_sale');
expect(consumeCampaignSlug()).toBeNull();
});
});
// Компоненты в этом репозитории не рендерятся в тестах (vitest на node, без
// jsdom), поэтому отправку слага фиксируем по исходнику: без неё покупка
// гостем не попадает в статистику кампании и не даёт её бонус.
describe('QuickPurchase source', () => {
const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8');
it('puts the campaign slug into the purchase payload', () => {
expect(source).toContain('data.campaign_slug = campaignSlug');
});
it('reads the slug without consuming it', () => {
expect(source).toContain('getPendingCampaignSlug()');
expect(source).not.toContain('consumeCampaignSlug');
});
});

View File

@@ -0,0 +1,127 @@
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readContactPrefill, stripContactFromUrl } from './contactPrefill';
const STORAGE_KEY = 'lp_contact_promo';
let replaced: string[] = [];
function fakeLocalStorage(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
key: (index: number) => [...store.keys()][index] ?? null,
removeItem: (key: string) => void store.delete(key),
setItem: (key: string, value: string) => void store.set(key, value),
} as Storage;
}
function stubLocation(search: string, pathname = '/buy/promo', hash = ''): void {
vi.stubGlobal('window', {
location: { search, pathname, hash },
history: {
replaceState: (_state: unknown, _title: string, url: string) => replaced.push(url),
},
});
}
beforeEach(() => {
replaced = [];
vi.stubGlobal('localStorage', fakeLocalStorage());
stubLocation('');
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('readContactPrefill', () => {
it('takes the contact from the URL', () => {
stubLocation('?contact=client%40example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('client@example.com');
});
it('keeps the @ of a telegram username', () => {
stubLocation('?contact=%40durov');
expect(readContactPrefill(STORAGE_KEY)).toBe('@durov');
});
it('prefers the URL over the remembered value', () => {
localStorage.setItem(STORAGE_KEY, 'old@example.com');
stubLocation('?contact=new%40example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('new@example.com');
});
it('falls back to the remembered value', () => {
localStorage.setItem(STORAGE_KEY, 'old@example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('old@example.com');
});
it('returns an empty string when there is nothing to prefill', () => {
expect(readContactPrefill(STORAGE_KEY)).toBe('');
});
});
describe('stripContactFromUrl', () => {
// Лендинг поднимает Яндекс.Метрику с webvisor: оставленный в адресе email
// уедет в аналитику, в Referer при переходе на оплату и в историю браузера.
it('removes the contact from the address bar', () => {
stubLocation('?contact=client%40example.com');
stripContactFromUrl();
expect(replaced).toEqual(['/buy/promo']);
});
it('keeps the other query params', () => {
stubLocation('?campaign=summer&contact=client%40example.com&subid=42');
stripContactFromUrl();
expect(replaced).toHaveLength(1);
const params = new URLSearchParams(replaced[0].split('?')[1]);
expect(params.get('campaign')).toBe('summer');
expect(params.get('subid')).toBe('42');
expect(params.has('contact')).toBe(false);
});
it('keeps the hash', () => {
stubLocation('?contact=client%40example.com', '/buy/promo', '#tariffs');
stripContactFromUrl();
expect(replaced).toEqual(['/buy/promo#tariffs']);
});
it('does not touch the URL when there is no contact param', () => {
stubLocation('?campaign=summer');
stripContactFromUrl();
expect(replaced).toEqual([]);
});
});
// Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому вызовы
// фиксируем по исходнику: чтение без очистки оставит контакт в адресе, а это
// вся суть второй функции.
describe('QuickPurchase source', () => {
const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8');
it('prefills the contact field from the URL', () => {
expect(source).toContain('readContactPrefill(contactKey)');
});
it('cleans the contact out of the address bar', () => {
expect(source).toContain('stripContactFromUrl()');
});
});

View File

@@ -0,0 +1,41 @@
/**
* Предзаполнение поля контакта на странице быстрой покупки.
*
* Персональную ссылку вида `/buy/slug?contact=@username` формирует бот или
* Happ, чтобы клиенту не пришлось вводить контакт вручную.
*/
const CONTACT_PARAM = 'contact';
/**
* Контакт для формы: сначала параметр URL, иначе последнее введённое значение.
*/
export function readContactPrefill(storageKey: string): string {
try {
const fromUrl = new URLSearchParams(window.location.search).get(CONTACT_PARAM);
return fromUrl || localStorage.getItem(storageKey) || '';
} catch {
return '';
}
}
/**
* Убирает `contact` из адресной строки, сохраняя остальные параметры.
*
* Вызывать сразу после чтения. В параметре лежит личный email или @username, а
* лендинг инициализирует Яндекс.Метрику (та пишет URL страницы, а с
* `webvisor` — ещё и запись сессии). Без очистки контакт уезжает в аналитику,
* в `Referer` при переходе на оплату, в историю браузера и в логи. Тот же приём
* применяет `captureCampaignFromUrl()`.
*/
export function stripContactFromUrl(): void {
try {
const params = new URLSearchParams(window.location.search);
if (!params.has(CONTACT_PARAM)) return;
params.delete(CONTACT_PARAM);
const search = params.toString();
const url = window.location.pathname + (search ? `?${search}` : '') + window.location.hash;
window.history.replaceState(null, '', url);
} catch {}
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { GEOCHECK_MIN_NODE_VERSION, isVersionAtLeast, supportsGeoCheck } from './nodeVersion';
describe('isVersionAtLeast', () => {
it.each([
['3.3.0', true],
['3.3.1', true],
['3.4.0', true],
['4.0.0', true],
['v3.3.0', true],
['3.3', true],
['3.2.9', false],
['3.2.99', false],
['2.9.9', false],
['3', false],
])('%s vs 3.3.0 -> %s', (value, expected) => {
expect(isVersionAtLeast(value, '3.3.0')).toBe(expected);
});
it('сравнивает числа, а не строки: 3.10.0 новее 3.9.0', () => {
expect(isVersionAtLeast('3.10.0', '3.9.0')).toBe(true);
});
it('пререлиз целевой версии считается достаточным — фича там уже есть', () => {
expect(isVersionAtLeast('3.3.0-rc.1', '3.3.0')).toBe(true);
});
it.each([
undefined,
null,
'',
'unknown',
'dev',
])('нераспознанное значение %s -> false', (value) => {
expect(isVersionAtLeast(value, '3.3.0')).toBe(false);
});
});
describe('supportsGeoCheck', () => {
it('смотрит именно на версию узла, а не xray', () => {
expect(supportsGeoCheck({ node: '3.3.0' })).toBe(true);
expect(supportsGeoCheck({ node: '3.2.0' })).toBe(false);
});
it.each([undefined, null, {}])('без версии узла — false (%s)', (versions) => {
expect(supportsGeoCheck(versions as { node?: string } | null | undefined)).toBe(false);
});
it('порог совпадает с задокументированным минимумом', () => {
expect(supportsGeoCheck({ node: GEOCHECK_MIN_NODE_VERSION })).toBe(true);
});
});

43
src/utils/nodeVersion.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Сравнение версий узла Remnawave.
*
* Панель отдаёт `versions: { xray, node }` строками вида `3.3.0`; иногда с
* префиксом `v` или суффиксом пререлиза (`3.3.0-rc.1`). Нам нужен только
* порог «фича появилась начиная с версии X», поэтому сравниваем три числа
* и намеренно НЕ разбираем пререлизы по правилам semver: `3.3.0-rc.1`
* считается достаточным для 3.3.0 — фича там уже есть.
*/
/** Версия узла, при которой в панели появился GeoCheck. */
export const GEOCHECK_MIN_NODE_VERSION = '3.3.0';
function parseVersion(value: string): [number, number, number] | null {
const match = /^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(value);
if (!match) return null;
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
}
/** `true`, если `value` не старше `minimum`. Нераспознанная версия — `false`. */
export function isVersionAtLeast(value: string | null | undefined, minimum: string): boolean {
if (!value) return false;
const parsed = parseVersion(value);
const floor = parseVersion(minimum);
if (!parsed || !floor) return false;
for (let i = 0; i < 3; i += 1) {
if (parsed[i] !== floor[i]) return parsed[i] > floor[i];
}
return true;
}
/**
* Умеет ли узел в GeoCheck.
*
* Гейт по версии УЗЛА, а не панели: панель без 3.3.0 ответит на запрос 404, и
* бэкенд превратит это в понятную ошибку — а вот кнопку на заведомо старом
* узле показывать незачем.
*/
export function supportsGeoCheck(versions: { node?: string } | null | undefined): boolean {
return isVersionAtLeast(versions?.node, GEOCHECK_MIN_NODE_VERSION);
}

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const retrieveRawInitData = vi.fn<() => string | undefined>();
vi.mock('@telegram-apps/sdk-react', () => ({
retrieveRawInitData: () => retrieveRawInitData(),
}));
const { getTelegramInitData } = await import('./telegramInitData');
/** initData ровно той формы, что приходит от Telegram. */
function initData(authDate: number): string {
const user = encodeURIComponent(JSON.stringify({ id: 1, first_name: 'A' }));
return `user=${user}&auth_date=${authDate}&signature=s&hash=h${authDate}`;
}
const OLD = initData(1_700_000_000);
const FRESH = initData(1_755_000_000);
function setBridge(value: string | undefined): void {
vi.stubGlobal('window', value === undefined ? {} : { Telegram: { WebApp: { initData: value } } });
}
beforeEach(() => {
retrieveRawInitData.mockReset();
setBridge(undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('getTelegramInitData', () => {
it('uses the Telegram bridge value', () => {
setBridge(FRESH);
retrieveRawInitData.mockReturnValue(undefined);
expect(getTelegramInitData()).toBe(FRESH);
});
it('falls back to the SDK when the bridge script has not loaded', () => {
setBridge(undefined);
retrieveRawInitData.mockReturnValue(FRESH);
expect(getTelegramInitData()).toBe(FRESH);
});
// Ради этого правка и делается: SDK берёт параметры запуска из записи о
// навигации и из своего кэша в sessionStorage, а оба привязаны к документу,
// а не к текущему запуску мини-аппы. На iOS WebView переживает переоткрытия,
// и SDK молча отдаёт initData прошлой недели — бэкенд её отвергает.
it('prefers the bridge when the SDK serves a stale cached launch', () => {
setBridge(FRESH);
retrieveRawInitData.mockReturnValue(OLD);
expect(getTelegramInitData()).toBe(FRESH);
});
// Обратный случай встречается на других платформах, поэтому выбираем не
// «первый доступный», а самый свежий по auth_date.
it('prefers the SDK when the bridge itself is the stale one', () => {
setBridge(OLD);
retrieveRawInitData.mockReturnValue(FRESH);
expect(getTelegramInitData()).toBe(FRESH);
});
it('keeps the bridge value when both are equally fresh', () => {
const bridgeCopy = `${FRESH}&tgWebAppBotInline=0`;
setBridge(bridgeCopy);
retrieveRawInitData.mockReturnValue(FRESH);
expect(getTelegramInitData()).toBe(bridgeCopy);
});
it('treats an empty bridge value as absent', () => {
setBridge('');
retrieveRawInitData.mockReturnValue(FRESH);
expect(getTelegramInitData()).toBe(FRESH);
});
it('survives the SDK throwing outside Telegram', () => {
setBridge(FRESH);
retrieveRawInitData.mockImplementation(() => {
throw new Error('LaunchParamsRetrieveError');
});
expect(getTelegramInitData()).toBe(FRESH);
});
it('returns null when there is no init data at all', () => {
setBridge(undefined);
retrieveRawInitData.mockImplementation(() => {
throw new Error('LaunchParamsRetrieveError');
});
expect(getTelegramInitData()).toBeNull();
});
it('does not crash on init data without a usable auth_date', () => {
setBridge('user=%7B%7D&hash=h');
retrieveRawInitData.mockReturnValue('auth_date=not-a-number&hash=h');
expect(getTelegramInitData()).toBe('user=%7B%7D&hash=h');
});
});
// Смысл правки — единственная точка чтения initData. Прямой вызов
// retrieveRawInitData() в обход неё вернёт ту самую протухшую копию из кэша
// SDK, причём молча: ошибки не будет, вход просто перестанет работать.
describe('single source of init data', () => {
it('is read through this module only', async () => {
const { readdirSync, readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const srcDir = new URL('..', import.meta.url).pathname;
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!/\.tsx?$/.test(entry.name)) continue;
if (full.endsWith(join('utils', 'telegramInitData.ts'))) continue;
if (full.endsWith(join('utils', 'telegramInitData.test.ts'))) continue;
if (readFileSync(full, 'utf8').includes('retrieveRawInitData')) {
offenders.push(full.slice(srcDir.length));
}
}
};
walk(srcDir);
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,63 @@
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
/**
* Сырая строка initData Telegram, прочитанная в момент вызова.
*
* Почему мало одного `retrieveRawInitData()` из @telegram-apps/sdk: он ищет
* параметры запуска по цепочке `location.href` → запись performance о навигации
* → собственный кэш `tapps/launchParams` в sessionStorage, записывая удачный
* результат обратно в кэш. Последние два источника привязаны не к текущему
* запуску мини-аппы, а к документу: как только SPA сменила маршрут и hash с
* `tgWebAppData` ушёл из адреса, остаются запись о навигации исходного
* документа и кэш — оба из ПЕРВОГО запуска в этой сессии WebView. На iOS такой
* WebView переживает переоткрытия мини-аппы, и SDK молча отдаёт initData
* недельной давности, без единой ошибки.
*
* Цена — отказ входа: бэкенд принимает initData с `auth_date` не старше 30
* суток, и застрявшая копия рано или поздно пересекает порог, после чего
* логин отвечает «Invalid or expired Telegram authentication data».
*
* Поэтому спрашиваем ещё и `window.Telegram.WebApp.initData` — его пишет
* официальный мост Telegram (telegram-web-app.js, подключается в index.html),
* и он не завязан на кэш SDK. Какой из источников протух, зависит от платформы
* и способа открытия, поэтому берём не «первый доступный», а тот, у которого
* `auth_date` больше.
*/
export function getTelegramInitData(): string | null {
if (typeof window === 'undefined') return null;
const candidates = [fromTelegramBridge(), fromSdk()].filter((value): value is string =>
Boolean(value),
);
if (candidates.length === 0) return null;
// При равном или неразобранном auth_date побеждает мост: он ближе к
// источнику, чем кэш SDK.
return candidates.reduce((best, candidate) =>
authDateOf(candidate) > authDateOf(best) ? candidate : best,
);
}
function fromTelegramBridge(): string | null {
return window.Telegram?.WebApp?.initData || null;
}
function fromSdk(): string | null {
try {
return retrieveRawInitData() || null;
} catch {
// Вне Telegram параметров запуска нет — это не ошибка.
return null;
}
}
/** Метка времени выдачи initData; 0, если её не удалось разобрать. */
function authDateOf(initData: string): number {
try {
const raw = new URLSearchParams(initData).get('auth_date');
const parsed = Number(raw);
return raw && Number.isFinite(parsed) ? parsed : 0;
} catch {
return 0;
}
}

View File

@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs';
import { AxiosError, AxiosHeaders } from 'axios';
import { describe, expect, it } from 'vitest';
import { isOpenTicketConflict } from './ticketErrors';
function axiosErrorWithStatus(status: number, detail?: unknown): AxiosError {
const headers = new AxiosHeaders();
const config = { headers };
return new AxiosError(
'Request failed',
'ERR_BAD_REQUEST',
config,
{},
{
status,
statusText: '',
headers,
config,
data: detail === undefined ? {} : { detail },
},
);
}
describe('isOpenTicketConflict', () => {
it('ловит 409 от POST /cabinet/tickets', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(409, 'You already have an open ticket'))).toBe(
true,
);
});
it('не зависит от текста detail (он приходит с бэка по-английски)', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(409))).toBe(true);
expect(isOpenTicketConflict(axiosErrorWithStatus(409, { code: 'open_ticket_exists' }))).toBe(
true,
);
});
it('не путает с другими отказами бэка', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(403, 'Support tickets are disabled'))).toBe(
false,
);
expect(isOpenTicketConflict(axiosErrorWithStatus(400))).toBe(false);
expect(isOpenTicketConflict(axiosErrorWithStatus(500))).toBe(false);
});
it('не падает на не-axios ошибках', () => {
expect(isOpenTicketConflict(new Error('boom'))).toBe(false);
expect(isOpenTicketConflict(undefined)).toBe(false);
expect(isOpenTicketConflict(null)).toBe(false);
});
});
describe('обработчики ошибок на форме поддержки', () => {
// Решение «это 409» вынесено в утилиту и покрыто выше, а вот сама привязка
// onError к мутациям — нет: в репозитории нет окружения для рендера
// компонентов (vitest настроен на node, без jsdom). Поэтому закрепляем
// структурно: пропажа onError — это ровно тот баг, который PR и чинит
// (форма молчала, человек жал «Отправить» повторно).
const source = readFileSync(new URL('../pages/Support.tsx', import.meta.url), 'utf8');
it('createMutation обрабатывает отказ бэка', () => {
const block = source.slice(
source.indexOf('const createMutation'),
source.indexOf('const replyMutation'),
);
expect(block).toContain('onError');
expect(block).toContain('isOpenTicketConflict');
});
it('replyMutation обрабатывает отказ бэка', () => {
const block = source.slice(
source.indexOf('const replyMutation'),
source.indexOf('const getStatusBadge'),
);
expect(block).toContain('onError');
});
it('409 подтягивает список тикетов — открытый мог появиться в другой сессии', () => {
const block = source.slice(
source.indexOf('isOpenTicketConflict(error)'),
source.indexOf('const replyMutation'),
);
expect(block).toContain('invalidateQueries');
});
});

14
src/utils/ticketErrors.ts Normal file
View File

@@ -0,0 +1,14 @@
import axios from 'axios';
/**
* true, если бэк отказал в создании тикета из-за уже открытого обращения.
*
* `POST /cabinet/tickets` отвечает 409 ровно в одном случае — у пользователя
* есть незакрытый тикет (`detail: 'You already have an open ticket'`, паритет
* с бот-путём). Сверяемся со статусом, а не с текстом: detail приходит с бэка
* только по-английски, а кабинет и бот версионируются раздельно — текст может
* поменяться в любой сборке бота, статус останется.
*/
export function isOpenTicketConflict(error: unknown): boolean {
return axios.isAxiosError(error) && error.response?.status === 409;
}

View File

@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
/**
* Тело сообщения тикета рендерится в трёх местах, и во всех трёх текст должен
* переноситься по любому символу.
*
* `whitespace-pre-wrap` переносит только по пробелам, поэтому неразрывный токен
* — ссылка на подписку, ключ, base64 — уезжает за пузырь. Заметить это нельзя:
* контейнер сообщений идёт с `overflow-y-auto` (браузер добирает `overflow-x`
* в `auto`), а полосу прокрутки прячет `.scrollbar-hide`. Замер в браузере на
* ширине 1280px: без `break-words` контейнер 803px против содержимого 3179px,
* то есть 2376px текста недостижимы ни колесом, ни глазом.
*
* Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому класс
* проверяется по исходнику — рядом с тем самым `linkifyText(msg.message_text)`,
* а не где угодно в файле.
*/
const RENDERERS = [
'src/pages/Support.tsx',
'src/pages/AdminTickets.tsx',
'src/components/admin/userDetail/TicketsTab.tsx',
];
// className="..." непосредственно перед выводом тела сообщения
const MESSAGE_BODY_RE =
/className="([^"]*)"\s*\n\s*dangerouslySetInnerHTML=\{\{\s*__html:\s*linkifyText\(msg\.message_text\)/g;
function messageBodyClasses(file: string): string[] {
const source = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
return [...source.matchAll(MESSAGE_BODY_RE)].map((match) => match[1]);
}
describe.each(RENDERERS)('%s', (file) => {
it('renders the ticket message body', () => {
expect(messageBodyClasses(file).length).toBeGreaterThan(0);
});
it('wraps long unbreakable tokens', () => {
for (const classes of messageBodyClasses(file)) {
expect(classes).toContain('whitespace-pre-wrap');
expect(classes).toContain('break-words');
}
});
});

6
src/vite-env.d.ts vendored
View File

@@ -21,6 +21,12 @@ interface TelegramWebAppGlobal {
offEvent?: (event: string, callback: () => void) => void;
/** Closes the Mini App (injected by telegram-web-app.js). */
close?: () => void;
/**
* Raw init data, snapshotted by Telegram's own bridge script at page load.
* Independent of the @telegram-apps/sdk launch-params cache, which can go
* stale — see src/utils/telegramInitData.ts.
*/
initData?: string;
}
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */