Commit Graph

1532 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
Fringg
0f21629ba5 feat(auth): чекбоксы согласия с офертой и политикой при первой авторизации
Новый пользователь попадал в кабинет молча — никаких документов ему не показывали.
Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой.

Набор документов и сам факт включения гейта приходят с бэка
(GET /cabinet/info/legal-consent): там же учитывается, заполнены ли документы и
видны ли они в вебе. Фронт знает только ключи и адреса публичных страниц /offer
и /privacy — тех же, что уже линкует футер логина.

Два пути входа устроены по-разному, поэтому и UI разный:
- регистрация по email — чекбоксы прямо в форме, кнопка заблокирована до галочек;
- Telegram-вход происходит сам собой, спрашивать заранее не у чего. Поэтому ловим
  428 от бэка и показываем отдельный экран согласия, а замыкание помнит, какой
  именно вход повторить после простановки галочек. Тот же приём страхует и
  email-форму: конфиг мог протухнуть, если админ включил гейт между загрузкой
  страницы и отправкой.

Неизвестный ключ документа всё равно рисуем (без ссылки): бэк его требует, и молча
спрятанный чекбокс превратился бы в «кнопка не работает и непонятно почему».

Локали ru/en/zh/fa.
2026-07-30 00:31:47 +03:00
Fringg
b615927283 fix(security): SRI для CDN-скрипта сканера QR
Автоматическое ревью справедливо отметило загрузку html5-qrcode с CDN без
проверки целостности: подмена файла на стороне CDN исполнилась бы в кабинете
с полными правами страницы (доступ к сессии, initData Telegram).

Скрипт подключается с integrity (sha384 сверен по фактическому файлу
html5-qrcode@2.3.8), crossorigin=anonymous и referrerPolicy=no-referrer. При
несовпадении хеша браузер откажется исполнять скрипт, и сканер деградирует в
«камера недоступна» вместо запуска чужого кода.

Заодно закрыт тот же изъян в исходном месте, откуда пришёл паттерн:
TvQuickConnect грузил тот же файл с jsdelivr вообще без проверки. Он
переведён на общий хелпер (файлы на unpkg и jsdelivr побайтово идентичны —
сверил), дублирующийся загрузчик и интерфейс удалены.

Виджеты telegram.org намеренно оставлены без SRI: это самообновляемые
эндпоинты Telegram, фиксация хеша сломала бы логин при их обновлении.
2026-07-29 22:14:32 +03:00
Fringg
0e4dc43335 feat(gift): QR подарка и сканирование в кабинете
Получателю раньше оставалось только скопировать ссылку или код руками.

- QR на экране готового подарка: кодируется bot-ссылка (активация в боте —
  основной путь), при отсутствии username бота — ссылка кабинета. Использован
  уже имеющийся qrcode.react, на белой подложке ради контраста в тёмной теме.
- Кнопка «Отсканировать QR» в форме активации. В Telegram используется
  НАТИВНЫЙ сканер (в WebView камера через getUserMedia работает ненадёжно —
  ровно то, из-за чего скан «в тг» и был проблемой), в вебе — html5-qrcode с
  CDN, с фолбэком на фронтальную камеру и понятной ошибкой, если камеры нет.
- src/utils/qrScanner.ts: логика сканера вынесена из TvQuickConnect, чтобы её
  мог переиспользовать любой экран. parseGiftCode понимает все три способа
  распространения подарка (deep-link бота, ссылка кабинета, голый код с
  префиксом GIFT- и без) и ОТКЛОНЯЕТ посторонние QR — иначе сканер подставил
  бы в поле мусор с любой ссылки или Wi-Fi-кода.
- Веб-сканер гасится при уходе с вкладки и размонтировании, иначе индикатор
  камеры остаётся гореть.

Локали ru/en/zh/fa, +4 теста на парсер.
2026-07-29 22:11:04 +03:00
Fringg
390da33914 feat(coupons): лимит на пользователя и удаление партии в кабинете
Парная фронтовая часть к бэкенду (бот: 82609441).

- Поле «Лимит на пользователя» в форме создания партии: 0 — без
  ограничения, для раздач и конкурсов ставится 1. Значение показывается в
  карточке партии, если задано.
- Кнопка «Удалить партию» с подтверждением: отдельно от отзыва, который
  лишь гасит ссылки. Если в партии есть погашенные купоны, подтверждение
  предупреждает о потере истории активаций (выданные подписки не
  отзываются). После удаления — возврат к списку.
- API: max_per_user в типах партии и запросе создания, deleteBatch.

Локали ru/en — раздел admin.coupons в zh/fa отсутствует целиком (купоны там
никогда не переводились), поэтому новые ключи добавлены в том же объёме.
2026-07-29 22:02:07 +03:00
Fringg
c9afea7a50 feat(branding): загрузка видео стартового меню бота
Парная фронтовая часть к /cabinet/branding/bot-start-video (бот: 524960c3).
В «Брендинге» появилась карточка: загрузить/заменить/удалить видео, которое
бот прикрепляет к сообщению /start вместо картинки-логотипа, и статус
текущего состояния. Файл разово уходит в Telegram ради file_id — на нашей
стороне не хранится.

Видео в рассылках уже работало (кнопка в боте, загрузка в кабинете,
доставка send_video) и не затронуто. Локали ru/en/zh/fa.
2026-07-29 21:28:48 +03:00
Fringg
a28f1167f7 feat(lava): покупка привязкой, продукт тарифа в админке, CTA
Фронтовые хвосты автопродления Lava (бэкенд: de3518b7).

- Поле «Продукт Lava» в форме создания/редактирования тарифа: раньше
  lava_product_id можно было задать только через API, из-за чего фичу нельзя
  было включить из интерфейса. Пустая строка отвязывает тариф от продукта.
- CTA «Оформить с автооплатой Lava» в форме покупки тарифа — рядом с
  СБП-кнопкой, в обеих ветках рендера; флаг lava_recurrent_enabled из
  purchase-options наконец получил потребителя.
- API-метод purchaseWithLavaRecurring + типы lava_product_id в списке,
  детали и запросах тарифа.
- Локали ru/en/zh/fa: подписи покупки и поля продукта (плейсхолдеры сверены).
2026-07-29 20:23:35 +03:00
Fringg
2dce539da2 feat(lava): автопродление Lava на странице подписки
Парная фронтовая часть к эндпоинтам /cabinet/subscription/lava-recurrent
(бот: 0dea2182). Блок — сиблинг Platega-блока с той же семантикой состояний
(off/pending/active/past_due), поллинг раз в 8с пока привязка PENDING.

Отличие от Platega в подписи: у Lava период задан продуктом в кабинете
провайдера и приезжает числом дней, поэтому канонические значения (30/90/365
и т.д.) показываются словами, а произвольные — как «раз в N дн.».

utils/lavaRecurring.ts: строгое распознавание 403 'Lava recurrent disabled'
(другие 403-guard'ы прятать нельзя), деградация неизвестного статуса в off.
Локали ru/en/zh/fa (+25 строк каждая, плейсхолдеры сверены). 7 тестов утилиты.
2026-07-29 16:34:43 +03:00
Fringg
62aaa5d7c9 fix(admin): фиксированный телеграм-синий у свотча «Синий» вместо акцента темы
Свотч и превью-чип стиля primary красились в bg-accent-500 — акцент темы
кабинета, который перекрашивается (у оранжевой темы «Синий» выглядел
оранжевым). Реальный цвет кнопки задаёт Telegram Bot API: primary — синий
независимо от темы кабинета, поэтому свотч теперь фиксированный #54a9eb
(тот же телеграм-синий, что у кнопки Telegram-логина).
2026-07-29 14:45:36 +03:00
Egor
2f04f29b68 Merge PR #526: охват сегментов и прогресс доставки на странице отправки 2026-07-29 12:29:29 +03:00
Fringg
516399cef9 fix(i18n): существующие ключи вместо отсутствующих common.units.* на экране продления
common.units.mo и common.units.days не существуют ни в одной локали — en/zh/fa
видели русский фолбэк «мес»/«дней», а PR #524 расширил видимость строки
«/мес» (периоды 31–44 дней). subscription.month и subscription.days есть во
всех четырёх локалях и уже используются карточкой периодов тарифа.
2026-07-29 04:51:27 +03:00
Egor
73cf2e265d Merge PR #524: корректная цена за месяц при выборе периода 2026-07-29 04:50:40 +03:00
kewldan
c6d8c0afe0 feat(promo-offers): охват сегментов и прогресс доставки на странице отправки
Сегмент выбирался вслепую — в списке не было числа пользователей, а после
отправки экран показывал только количество созданных офферов: сколько
человек реально получило предложение и сколько заблокировало бота, узнать
было негде.

В селекте сегментов теперь стоит охват каждого (GET /promo-offers/segments),
под ним — сколько уйдёт по выбранному. После отправки экран результата
показывает прогресс доставки с карточками Всего/Отправлено/Заблокировали/
Ошибки и опрашивает запись рассылки раз в 3 секунды, пока она не завершится.

Прогресс-блок и бейдж статуса вынесены из AdminBroadcastDetail в
BroadcastDeliveryStats и переиспользуются обеими страницами, а условие
«доставка ещё идёт» — в утилиту broadcastStatus с тестами.
2026-07-28 22:24:58 +03:00
kewldan
888a02f397 fix(subscription): корректная цена за месяц при выборе периода
Для периодов короче месяца в карточке периода под ценой выводилась
та же сумма с подписью «/мес»: цена за 7 дней показывалась как цена
за месяц. Причина — деление на max(1, days / 30), которое для коротких
периодов даёт делитель 1.

Расчёт вынесен в getMonthlyPriceKopeks: месячная ставка считается
пропорцией (price * 30 / days) и не показывается для периодов в месяц
и короче, где она либо дублирует цену, либо вводит в заблуждение.
Тот же хелпер применён на экране продления, где месячная цена для
периодов вроде 45 дней считалась делением на округлённое число
месяцев.
2026-07-28 21:23:08 +03:00
kewldan
fb1f5cf3a7 fix(i18n): починка плейсхолдеров и переводов в zh/fa/en локалях кабинета
Аудит локалей кабинета с русским в качестве эталона.

Битые {{плейсхолдеры}} в zh/fa (рендерились как литеральные скобки,
теряли значение или дублировали то, что и так выводится в JSX):
- gift.sentTo / gift.activatedBy: неверное имя переменной
  ({{name}} → {{recipient}} / {{username}})
- dashboard.devicesConnectedUnlimited: {{count}} → {{used}} (+ «без лимита»)
- dashboard.maxUsage, gift.deviceCount, gift.activateSuccessDesc: потерянные
  значения возвращены
- dashboard.expired.activeUntil, subscription.trialInfo.remaining: убран
  {{date}}/{{count}} — значение выводится отдельным элементом
- trialInfo.description, trialOffer.freeDesc/paidDesc, gift.shareText:
  переписаны без {{days}}/{{price}}/{{code}}, которые код не передаёт
- wheel.payWithStars: одинарные {stars} (i18next их не подставляет) убраны

Мисперводы zh/fa (разошлись по смыслу с ru/en):
- subscription.cta.*Hint, expiredBanner.selectTariff, trialUpgrade.description,
  reduceDevicesDescription, menuEditor.customLabelsHint, myGiftsEmptyDesc,
  partners.settingsFields.*, remnawave.overview.bandwidth, shareModalActivateVia

fa admin.pinnedMessages: 40 строк были на английском → переведены на фарси.
zh common.all: 'All' → '全部'.
en admin.broadcasts.subtitle: устаревшее «History and management» →
соответствует ru «Массовая отправка сообщений пользователям».

Плейсхолдеры и синхронность en/ru проверены (locales.test.ts).
2026-07-26 02:09:24 +03:00
Fringg
51d0f0431a fix(security): инлайновые regex-барьеры для CodeQL в openAppScheme и redirect.html
Хелпер-функции (isSafeToOpen/isSafeAppLink) CodeQL как санитайзеры не
распознаёт — js/xss и js/client-side-unvalidated-url-redirection
продолжали флаговаться на sink'ах. Проверки перенесены инлайн к sink'ам
как префикс-якорные RegExp-гарды (распознаваемый барьер): scheme://
обязателен, javascript/data/vbscript/file/blob/about/intent/content/
filesystem (и http для redirect.html) отбрасываются. Семантика та же.
2026-07-25 00:32:00 +03:00
Fringg
aa6bf44db9 fix(security): внутренний гард openAppScheme от опасных схем (CodeQL)
Callers валидируют вход (allowlist в DeepLinkRedirect, конфиг в
Connection), но сама утилита доверяла аргументу — забытая валидация в
будущем call-site стала бы client-side XSS через
location.href='javascript:…'. Теперь схема парсится и javascript/data/
vbscript/file/blob/about/intent/content/filesystem отбрасываются до
любого sink'а (зеркало isSafeAppLink из redirect.html).
2026-07-25 00:20:52 +03:00
Fringg
17e27d3437 fix(sbp-recurring): отступ между тогглом автопродления и СБП-блоком
Карточки-соседи слипались: у тоггла нет нижнего маргина, у СБП-блока не
было верхнего, парент вертикальных отступов не даёт. mt-3 зеркально
внутреннему ритму карточки.
2026-07-24 19:23:46 +03:00
Fringg
f448c14994 feat(sbp-recurring): СБП-оформление подписки на форме покупки тарифа
Вторая CTA «Оформить с автооплатой СБП» под кнопкой покупки с баланса
(и в daily-ветке) — показывается по platega_recurrent_enabled из
purchase-options. POST /platega-recurrent/purchase → редирект в банк
(первое списание = подтверждение привязки), инвалидация подписочных и
sbp-запросов, переход в /subscriptions. Локали en/ru/zh/fa.
2026-07-24 19:07:23 +03:00
Fringg
faf4c450b0 fix(sbp-recurring): компактный layout СБП-блока на десктопе
Кнопки растягивались w-full на всю ширину карточки подписки (~1600px на
десктопе) — гигантская плашка вместо контрола. Теперь заголовок и статус
слева, компактное действие справа — зеркально соседнему тогглу
«Автопродление»; на мобиле кнопка по-прежнему на всю ширину
(w-full sm:w-auto).
2026-07-24 17:45:11 +03:00
Fringg
b8d5df223b Merge PR #509: отправлять пустые промогруппы и описание при PATCH тарифа
Форма слала undefined при пустом выборе, а PATCH применяет поле только
при его наличии — снять все промогруппы или очистить описание было
невозможно. Теперь при редактировании поля уходят всегда ([] и '');
create не меняется (бэкенд маппит пустое в None). Закрывает #455.
Спасибо @kewldan.
2026-07-24 16:41:19 +03:00
Fringg
2cd494e949 Merge PR #508: различать устаревшего бота и ненастроенного на логине
404 на /cabinet/branding/telegram-widget и deeplink-роутах = сборка бота
старше v3.24/v3.33 — вместо «бот не настроен» показываем auth.botOutdated
(4 локали). README: фактический минимум v3.33.0. Закрывает #345.
Спасибо @kewldan.
2026-07-24 15:00:36 +03:00
Fringg
90a4a7bf67 Merge PR #507: секция вывода средств — всем реферерам
Гейт partner_status === 'approved' был чисто фронтовым: бэкенд-эндпоинты
вывода и referral_withdrawal_service партнёрство не проверяют, а страница
запроса вывода тоже не гейтится. Секция остаётся под админским флагом
partner_section_visible. Закрывает #472. Спасибо @kewldan.
2026-07-24 14:54:57 +03:00
Fringg
05a7c6b02a Merge PR #506: приветствие без пустого имени для email-пользователей
displayName() получил фолбэк на локальную часть email; без имени вовсе —
dashboard.welcomeNoName без запятой (4 локали). Закрывает #423.
Спасибо @kewldan.
2026-07-24 14:50:21 +03:00
Fringg
21ab524aa4 Merge PR #503: deep-link на iOS через top-level переход
iOS запускает кастомную схему только из top-level навигации с жестом —
скрытый iframe молча игнорируется (кнопка «Добавить подписку» не
работала в Safari и миниаппе, больнее всего на iOS-first INCY).
iframe оставлен для Android in-app браузеров (ERR_UNKNOWN_URL_SCHEME).
Симметрично в openAppScheme.ts и redirect.html. Спасибо @AirP0WeR.
2026-07-24 14:30:10 +03:00
Fringg
8b666589eb Merge PR #502: ужесточить resolveSupportContact
Схема support_url валидируется (http/https/tg, чужое → null), legacy
SUPPORT_USERNAME проверяется регуляркой (URL-образное не клеится в
t.me/https://…), null → кнопка контакта не рендерится вместо дефолтного
@support. Ветки ticketsDisabled/both переведены на общий резолв.
Спасибо @AirP0WeR.
2026-07-24 14:21:59 +03:00
Fringg
2e17f8a7e0 Merge PR #505: локализовать дефолт экрана подписки на канал
Захардкоженный английский дефолт бэкенда из 403 channel_subscription_required
перебивал переведённую blocking.channel.defaultMessage. Хелпер
customChannelMessage() отбрасывает известный дефолт и пустые значения,
кастомный текст проходит как есть. Закрывает #311. Спасибо @kewldan.
2026-07-24 14:08:57 +03:00
Fringg
42a7190241 fix(sbp-recurring): toast ошибки отмены + per-sub ключ inline-confirm в админке
- cancelSbpMutation: onError-тост с detail бэкенда (зеркально enable) —
  молчаливый провал отмены выглядел как успех
- SubscriptionTab: ключ подтверждения cancelSbpRecurring_{subId} —
  взведённый confirm не переживает переключение подписки в пикере
- Локали: subscription.sbpRecurring.cancelError (en/ru/zh/fa)
2026-07-24 07:28:50 +03:00
Fringg
332849393c feat(admin): статус и отмена СБП-автопродления в карточке юзера 2026-07-24 06:45:00 +03:00
Fringg
e8409a9007 feat(ws): уведомления о событиях СБП-автопродления 2026-07-24 06:42:39 +03:00