22 Commits

Author SHA1 Message Date
github-actions[bot]
04c44c52de Merge remote-tracking branch 'upstream/main' 2026-07-30 08:13:41 +00:00
Egor
0c1510fbfa Merge pull request #531 from BEDOLAGA-DEV/release-please--branches--main--components--cabinet-frontend
chore(main): release 1.64.0
2026-07-30 00:45:47 +03:00
github-actions[bot]
602b3dd1b8 chore(main): release 1.64.0 2026-07-29 21:45:19 +00:00
Egor
456cc2022b Merge pull request #530 from BEDOLAGA-DEV/dev
Dev
2026-07-30 00:44:27 +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
Egor
9a6aa1e508 Merge pull request #528 from BEDOLAGA-DEV/dev
Dev
2026-07-29 23:59:22 +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
Egor
2e3c4ba930 Merge PR #523: починка плейсхолдеров и переводов в zh/fa/en локалях 2026-07-29 04:30:33 +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
chillpad
a6a7e9fb5c docs: add runbook for the sync/build/deploy automation 2026-07-26 20:08:22 +03:00
chillpad
b37b94c144 deploy: support private-repo downloads via gh CLI + rollback to a specific release tag 2026-07-26 17:47:40 +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
43 changed files with 2139 additions and 308 deletions

View File

@@ -1,5 +1,27 @@
# Changelog
## [1.64.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.63.0...v1.64.0) (2026-07-29)
### Features
* **auth:** чекбоксы согласия с офертой и политикой при первой авторизации ([0f21629](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0f21629ba516118790c5d7f65df18f485dfaeafe))
* **branding:** загрузка видео стартового меню бота ([c9afea7](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c9afea7a5043e2a3b02d2242a116878fc70a0be6))
* **coupons:** лимит на пользователя и удаление партии в кабинете ([390da33](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/390da33914eb3f992719e33acf85decac660a880))
* **gift:** QR подарка и сканирование в кабинете ([0e4dc43](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0e4dc43335135fe229ce3c695fd7fa2722eb87e4))
* **lava:** автопродление Lava на странице подписки ([2dce539](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2dce539da2a7c3128084ed2bc6bcdf560d440c75))
* **lava:** покупка привязкой, продукт тарифа в админке, CTA ([a28f116](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a28f1167f7af57bfc6e5c7c370849e7478ec459f))
* **promo-offers:** охват сегментов и прогресс доставки на странице отправки ([c6d8c0a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c6d8c0afe0a30b864a9d0834735fdc7b52d8f362))
### Bug Fixes
* **admin:** фиксированный телеграм-синий у свотча «Синий» вместо акцента темы ([62aaa5d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/62aaa5d7c9eaedb5a118469587a35585f9e4f549))
* **i18n:** починка плейсхолдеров и переводов в zh/fa/en локалях кабинета ([fb1f5cf](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fb1f5cf3a762f55f18806be97a0db973316ff6ad))
* **i18n:** существующие ключи вместо отсутствующих common.units.* на экране продления ([516399c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/516399cef9fa4a100b5dae979a04fafe0d05e634))
* **security:** SRI для CDN-скрипта сканера QR ([b615927](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b615927283d9ae530c03f6120b3c5794d92e0689))
* **subscription:** корректная цена за месяц при выборе периода ([888a02f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/888a02f39722c68e4beddefbd1e39c402dfa51cb))
## [1.63.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.62.0...v1.63.0) (2026-07-24)

152
docs/RUNBOOK.md Normal file
View File

@@ -0,0 +1,152 @@
# Раннбук: автосинк и деплой кабинета
Как устроено скрытие блока «Дополнительные опции» на странице Подписки и вся
автоматика вокруг него: приватный форк, ежедневный синк с апстримом, сборка
статики и выкладка на VPS.
Репозиторий: `chillpadclub/bedolaga-cabinet` (приватный форк
`BEDOLAGA-DEV/bedolaga-cabinet`).
## Суть задачи
На странице `Подписка` нужно было скрыть блок «Дополнительные опции» (докупка
устройств, трафика, управление серверами), не потеряв возможность и дальше
получать обновления апстрима — проект активно развивается, жить на статичном
форке было бы риском самим по себе.
Решение: приватный форк со своим патчем поверх + GitHub Action, который каждый
день сам подтягивает апстрим, накатывает патч и собирает готовую статику для
VPS.
## Патч: как спрятан блок
Файл — `src/pages/Subscription.tsx`. Условие рендера обёрнуто в `false &&`, но
не правкой существующей строки, а **обёрткой снаружи** — так, чтобы ни одна
оригинальная строка апстрима не менялась, только добавлены две строки до и две
после:
```tsx
{/* Additional Options — disabled on purpose */}
{false && (
<>
{subscription && // ← строка апстрима не тронута
(subscription.is_active || subscription.is_limited) &&
...
<div>...</div>
)}
</>
)}
```
Благодаря этому будущий `git merge` с апстримом конфликтует, только если
апстрим правит саму первую или последнюю строку блока — а не любую строку
внутри (докупка устройств, трафика и т.д.), которую вы всё равно не
используете.
Компоненты `DeviceTopupSheet`, `DeviceReductionSheet`, `TrafficTopupSheet`,
`ServerManagementSheet` остаются в коде и в сборке — просто не рендерятся. Это
осознанный выбор в пользу простоты патча, а не баг.
## Как работает автоматика
Один workflow — `.github/workflows/sync-and-build.yml`. Запускается по
расписанию (ежедневно, 06:00 UTC) и вручную кнопкой `Run workflow`.
1. **Merge апстрима**`git fetch` + `merge upstream/main` поверх форка.
2. **Чистка CI** — удаляет `dependabot.yml` и чужие workflow, если merge их
принёс.
3. **Push в форк** — только если merge реально что-то изменил.
4. **Сборка**`npm ci` + `npm run build:docker` (те же build-args, что и в
официальном образе).
5. **Релиз**`tar.gz` статики → GitHub Release с тегом `vX.Y.Z`.
6. **Уведомление** — сообщение в Telegram: готово или упало.
Если апстрим правит ровно ту же строку, что и патч, — шаг merge падает
**намеренно**. Чужой код не должен домёрживаться в обход человека. Приходит
сообщение в Telegram, конфликт разрешается руками локально, затем push — синк
продолжается.
Сборка и релиз происходят, только если merge реально что-то поменял (или при
ручном запуске) — вхолостую по расписанию ничего не собирается и не
публикуется.
## Файлы
| Путь | Назначение |
|---|---|
| `.github/workflows/sync-and-build.yml` | Весь пайплайн: синк, чистка, сборка, релиз, уведомления |
| `scripts/deploy-cabinet.sh` | Деплой на VPS: скачивает релиз, бэкапит текущую статику, раскатывает |
| `src/pages/Subscription.tsx` | Патч — скрытый блок «Дополнительные опции» |
| `docs/RUNBOOK.md` | Этот файл |
Удалено из форка сознательно (было в апстриме, плодило шум):
| Файл | Почему убран |
|---|---|
| `.github/dependabot.yml` | Открывал десятки PR на бамп версий — оставили только Dependabot alerts (без авто-PR) |
| `ci.yml`, `lint.yml` | Тесты/линт апстрима — код уже проверен в апстриме до мёржа |
| `codeql.yml`, `security-audit.yml` | Security-сканы апстрима, дублируют Dependabot alerts |
| `docker.yml` | Пуш официального образа в GHCR — не нужен, мы публикуем статику |
| `release-please.yml`, `release.yml` | Автобамп версии апстрима — версию читаем из `package.json` |
## Секреты и токены
| Что | Где хранится | Права |
|---|---|---|
| `TG_BOT_TOKEN` | GitHub → Settings → Secrets and variables → Actions | Отправка сообщений ботом |
| `TG_CHAT_ID` | Там же | — |
| Fine-grained PAT | VPS: `~/.cabinet-deploy-token`, права файла `600` | Только `Contents: Read-only` на этот репозиторий |
Токен для VPS специально ограничен одним репозиторием и правом только на
чтение — даже если файл утечёт, им нельзя ничего запушить или изменить.
## Как выкатить на прод
На VPS:
```bash
./deploy-cabinet.sh # последний релиз
./deploy-cabinet.sh v1.63.0 # откат/установка конкретной версии
```
Скрипт сам:
1. скачивает `cabinet-dist.tar.gz` через `gh release download`;
2. бэкапит текущую статику в `cabinet-dist.bak`;
3. раскатывает новую через `rsync -a --delete` (чистит устаревшие
хэшированные JS-чанки, не трогает `50x.html`).
Бэкап один, перезаписывается при каждом запуске. Откатиться на конкретную
версию надёжнее через `./deploy-cabinet.sh vX.Y.Z` (GitHub хранит все
релизы), а не полагаться только на `.bak`.
## Если что-то пошло не так
**Пришло Telegram-сообщение «⚠️ упал»** — почти всегда конфликт merge с
апстримом. Локально:
```bash
git fetch upstream main && git merge upstream/main
# разрешить конфликт (скорее всего в месте патча Subscription.tsx)
git add -A && git commit && git push origin main
```
Следующий прогон workflow снова пойдёт сам.
**Деплой на VPS выкатил битую сборку**:
```bash
rsync -a --delete cabinet-dist.bak/ cabinet-dist/ # мгновенный откат
# либо
./deploy-cabinet.sh vX.Y.Z # конкретный рабочий релиз
```
**Security-алерт в GitHub про уязвимость** — это Dependabot alerts (без
авто-PR). Апстрим обычно фиксит сам, фикс прилетит следующим синком. Если
критично и срочно — можно поправить зависимость руками отдельным коммитом.
## Что стоит проверять периодически
- **Раз в 12 месяца — вкладка Actions.** GitHub отключает scheduled-триггер
после ~60 дней без активности в репозитории. Один ручной `Run workflow`
включает обратно.
- **Бэк и фронт бота обновлять вместе.** Синк фронта и апдейт бэкенда бота —
по-прежнему ручной процесс, специально не автоматизировали дальше деплоя.
- **CHANGELOG апстрима перед выкаткой** — на случай breaking changes в API
между бэкендом и фронтом.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cabinet-frontend",
"version": "1.63.0",
"version": "1.64.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabinet-frontend",
"version": "1.63.0",
"version": "1.64.0",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "cabinet-frontend",
"private": true,
"version": "1.63.0",
"version": "1.64.0",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module",

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env bash
# Скачивает последнюю сборку из GitHub Release форка и раскатывает её
# Скачивает сборку из GitHub Release приватного репозитория и раскатывает её
# поверх продакшен-статики кабинета на VPS, с автоматическим бэкапом.
#
# Использование: ./deploy-cabinet.sh
# Использование:
# ./deploy-cabinet.sh — последний релиз
# ./deploy-cabinet.sh v1.63.0 — конкретный релиз (откат на версию)
#
# Откат при проблемах:
# Требует gh CLI, авторизованный токеном с правом Contents: Read-only на этот
# репозиторий. Токен лежит отдельно от скрипта — см. TOKEN_FILE ниже.
#
# Откат вручную при проблемах:
# rsync -a --delete "$DEST.bak/" "$DEST/"
set -euo pipefail
@@ -12,12 +17,28 @@ set -euo pipefail
REPO="chillpadclub/bedolaga-cabinet"
DEST="/home/etkr4k/remnawave-bedolaga-telegram-bot/cabinet-dist"
ASSET="cabinet-dist.tar.gz"
TOKEN_FILE="$HOME/.cabinet-deploy-token"
TAG="${1:-}"
if [ ! -f "$TOKEN_FILE" ]; then
echo "Не найден файл токена: $TOKEN_FILE" >&2
echo "Создайте его: echo '<токен>' > $TOKEN_FILE && chmod 600 $TOKEN_FILE" >&2
exit 1
fi
export GH_TOKEN
GH_TOKEN="$(cat "$TOKEN_FILE")"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
echo "Скачиваю последнюю сборку из $REPO..."
curl -fsSL "https://github.com/$REPO/releases/latest/download/$ASSET" -o "$TMP/$ASSET"
if [ -z "$TAG" ]; then
echo "Скачиваю последний релиз из $REPO..."
gh release download --repo "$REPO" --pattern "$ASSET" --dir "$TMP" --clobber
else
echo "Скачиваю релиз $TAG из $REPO..."
gh release download "$TAG" --repo "$REPO" --pattern "$ASSET" --dir "$TMP" --clobber
fi
echo "Распаковываю..."
mkdir -p "$TMP/dist"

View File

@@ -18,12 +18,14 @@ export const authApi = {
initData: string,
campaignSlug?: string | null,
referralCode?: string | null,
acceptedLegalDocuments?: string[],
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
init_data: initData,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
yandex_cid: getYandexCid() || undefined,
accepted_legal_documents: acceptedLegalDocuments,
});
return response.data;
},
@@ -40,12 +42,14 @@ export const authApi = {
},
campaignSlug?: string | null,
referralCode?: string | null,
acceptedLegalDocuments?: string[],
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
...data,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
yandex_cid: getYandexCid() || undefined,
accepted_legal_documents: acceptedLegalDocuments,
});
return response.data;
},
@@ -54,12 +58,14 @@ export const authApi = {
idToken: string,
campaignSlug?: string | null,
referralCode?: string | null,
acceptedLegalDocuments?: string[],
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
id_token: idToken,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
yandex_cid: getYandexCid() || undefined,
accepted_legal_documents: acceptedLegalDocuments,
});
return response.data;
},
@@ -115,6 +121,7 @@ export const authApi = {
language?: string;
referral_code?: string;
campaign_slug?: string;
accepted_legal_documents?: string[];
}): Promise<RegisterResponse> => {
const response = await apiClient.post<RegisterResponse>(
'/cabinet/auth/email/register/standalone',

View File

@@ -149,6 +149,11 @@ export const initLogoPreload = () => {
}
};
export interface BotStartVideoInfo {
has_video: boolean;
file_id?: string | null;
}
export const brandingApi = {
// Get current branding (public, no auth required)
getBranding: async (): Promise<BrandingInfo> => {
@@ -176,6 +181,29 @@ export const brandingApi = {
return response.data;
},
// ── Видео стартового меню бота ────────────────────────────────────────
// Хранится как Telegram file_id: файл на нашей стороне не сохраняется.
getBotStartVideo: async (): Promise<BotStartVideoInfo> => {
const response = await apiClient.get<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
return response.data;
},
uploadBotStartVideo: async (file: File): Promise<BotStartVideoInfo> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<BotStartVideoInfo>(
'/cabinet/branding/bot-start-video',
formData,
);
return response.data;
},
deleteBotStartVideo: async (): Promise<BotStartVideoInfo> => {
const response = await apiClient.delete<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
return response.data;
},
// Delete custom logo (admin only)
deleteLogo: async (): Promise<BrandingInfo> => {
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');

View File

@@ -10,6 +10,8 @@ export interface CouponBatch {
period_days: number;
coupons_total: number;
wholesale_price_kopeks: number;
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
max_per_user: number;
valid_until: string | null;
is_revoked: boolean;
created_at: string;
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
period_days: number;
coupons_count: number;
wholesale_price_kopeks?: number;
max_per_user?: number;
valid_days?: number;
}
@@ -99,6 +102,12 @@ export const couponsApi = {
},
// User: redeem a coupon for the current cabinet user
/** Полное удаление партии (в отличие от revoke, который гасит ссылки) */
deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => {
const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`);
return response.data;
},
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
return response.data;

View File

@@ -1,5 +1,5 @@
import apiClient from './client';
import type { SupportConfig } from '../types';
import type { LegalConsentConfig, SupportConfig } from '../types';
export interface FaqPage {
id: number;
@@ -125,4 +125,12 @@ export const infoApi = {
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
return response.data;
},
// Публичный: экран логина запрашивает это ДО авторизации.
getLegalConsentConfig: async (language?: string): Promise<LegalConsentConfig> => {
const response = await apiClient.get<LegalConsentConfig>('/cabinet/info/legal-consent', {
params: language ? { language } : undefined,
});
return response.data;
},
};

View File

@@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse {
created_offers: number;
user_ids: number[];
target: string | null;
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
notifications_sent: number;
notifications_failed: number;
/** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */
broadcast_id: number | null;
telegram_recipients: number;
}
export interface PromoOfferSegment {
key: string;
count: number;
}
export interface PromoOfferSegmentListResponse {
segments: PromoOfferSegment[];
}
export interface PromoOfferTemplate {
@@ -206,6 +219,12 @@ export const promoOffersApi = {
return response.data;
},
// Get recipient counts per target segment
getSegments: async (): Promise<PromoOfferSegmentListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/segments');
return response.data;
},
// Get promo offer logs
getLogs: async (params?: {
limit?: number;

View File

@@ -13,6 +13,7 @@ import type {
PurchasePreview,
AppConfig,
SbpRecurringInfo,
LavaRecurringInfo,
} from '../types';
/** Helper: build query params with optional subscription_id */
@@ -394,6 +395,49 @@ export const subscriptionApi = {
return response.data;
},
// ── Recurring (Lava) ──────────────────────────────────────────────────
getLavaRecurring: async (subscriptionId?: number): Promise<LavaRecurringInfo> => {
const response = await apiClient.get<LavaRecurringInfo>(
'/cabinet/subscription/lava-recurrent',
withSubId(subscriptionId),
);
return response.data;
},
enableLavaRecurring: async (
subscriptionId?: number,
): Promise<{ status: string; redirect_url: string | null }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/enable',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
cancelLavaRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/cancel',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
/**
* Оформление подписки на тариф привязкой Lava: первое списание оплачивается
* по возвращённой ссылке и активирует подписку.
*/
purchaseWithLavaRecurring: async (
tariffId: number,
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/purchase',
{},
{ params: { tariff_id: tariffId } },
);
return response.data;
},
// ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -35,6 +35,8 @@ export interface TariffListItem {
show_in_gift: boolean;
is_daily: boolean;
daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
traffic_limit_gb: number;
device_limit: number;
tier_level: number;
@@ -85,6 +87,8 @@ export interface TariffDetail {
// Дневной тариф
is_daily: boolean;
daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
// Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
// Внешний сквад Remnawave
@@ -124,6 +128,8 @@ export interface TariffCreateRequest {
// Дневной тариф
is_daily?: boolean;
daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика
traffic_reset_mode?: string | null;
// Внешний сквад Remnawave
@@ -168,6 +174,8 @@ export interface TariffUpdateRequest {
// Дневной тариф
is_daily?: boolean;
daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика
traffic_reset_mode?: string | null;
// Внешний сквад Remnawave

View File

@@ -0,0 +1,82 @@
import { useTranslation } from 'react-i18next';
// Галочки «ознакомлен» для НОВОГО пользователя. Набор документов приходит с бэка
// (GET /cabinet/info/legal-consent) — там же решается, включён ли гейт вообще и
// какие документы реально заполнены. Здесь только отрисовка и ссылки: адреса
// публичных страниц знает фронт, бэк отдаёт лишь ключи.
export const LEGAL_DOCUMENT_LINKS: Record<string, string> = {
public_offer: '/offer',
privacy_policy: '/privacy',
};
const LEGAL_DOCUMENT_LABELS: Record<string, { key: string; fallback: string }> = {
public_offer: { key: 'footer.offer', fallback: 'Публичная оферта' },
privacy_policy: { key: 'footer.privacy', fallback: 'Политика конфиденциальности' },
};
interface LegalConsentProps {
documents: string[];
accepted: Record<string, boolean>;
onChange: (document: string, value: boolean) => void;
disabled?: boolean;
className?: string;
}
export default function LegalConsent({
documents,
accepted,
onChange,
disabled = false,
className = '',
}: LegalConsentProps) {
const { t } = useTranslation();
if (documents.length === 0) return null;
return (
<div className={`space-y-2.5 ${className}`}>
{documents.map((document) => {
// Неизвестный ключ всё равно показываем: он обязателен на бэке, и молча
// спрятанный чекбокс превратился бы в «кнопка не работает без объяснений».
const label = LEGAL_DOCUMENT_LABELS[document];
const href = LEGAL_DOCUMENT_LINKS[document];
const title = label ? t(label.key, label.fallback) : document;
const inputId = `legal-consent-${document}`;
return (
<label
key={document}
htmlFor={inputId}
className="flex cursor-pointer items-start gap-2.5 text-xs leading-relaxed text-dark-400"
>
<input
id={inputId}
type="checkbox"
checked={Boolean(accepted[document])}
disabled={disabled}
onChange={(e) => onChange(document, e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500"
/>
<span>
{t('auth.legalConsentPrefix', 'Я ознакомлен(а) с документом')}{' '}
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent-400 underline underline-offset-2 transition-colors hover:text-accent-300"
onClick={(e) => e.stopPropagation()}
>
{title}
</a>
) : (
<span className="text-dark-200">{title}</span>
)}
</span>
</label>
);
})}
</div>
);
}

View File

@@ -15,6 +15,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const startVideoInputRef = useRef<HTMLInputElement>(null);
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
@@ -25,6 +26,12 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getBranding,
});
// Видео стартового меню бота: хранится как Telegram file_id
const { data: startVideo } = useQuery({
queryKey: ['bot-start-video'],
queryFn: brandingApi.getBotStartVideo,
});
const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
@@ -100,6 +107,29 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
},
});
const uploadStartVideoMutation = useMutation({
mutationFn: brandingApi.uploadBotStartVideo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
},
});
const deleteStartVideoMutation = useMutation({
mutationFn: brandingApi.deleteBotStartVideo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
},
});
const handleStartVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadStartVideoMutation.mutate(file);
}
// Сбрасываем input, чтобы повторный выбор того же файла снова сработал
e.target.value = '';
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -211,6 +241,57 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div>
</div>
{/* Видео стартового меню бота */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="mb-1 text-lg font-semibold text-dark-100">
{t('admin.settings.botStartVideo')}
</h3>
<p className="mb-4 text-sm text-dark-400">{t('admin.settings.botStartVideoDesc')}</p>
<div className="flex flex-wrap items-center gap-3">
<input
ref={startVideoInputRef}
type="file"
accept="video/*"
onChange={handleStartVideoUpload}
className="hidden"
/>
<button
onClick={() => startVideoInputRef.current?.click()}
disabled={uploadStartVideoMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{uploadStartVideoMutation.isPending
? t('common.loading')
: startVideo?.has_video
? t('admin.settings.botStartVideoReplace')
: t('admin.settings.botStartVideoUpload')}
</button>
{startVideo?.has_video && (
<button
onClick={() => deleteStartVideoMutation.mutate()}
disabled={deleteStartVideoMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
>
{t('admin.settings.botStartVideoRemove')}
</button>
)}
<span className="text-sm text-dark-400">
{startVideo?.has_video
? t('admin.settings.botStartVideoActive')
: t('admin.settings.botStartVideoNone')}
</span>
</div>
{uploadStartVideoMutation.isError && (
<div className="mt-3 text-sm text-error-400">
{t('admin.settings.botStartVideoError')}
</div>
)}
</div>
{/* Animated Background Editor */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<BackgroundEditor />

View File

@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
buttonStylesApi,
ButtonStylesConfig,
type ButtonStylesConfig,
DEFAULT_BUTTON_STYLES,
BUTTON_SECTIONS,
ButtonSection,
type ButtonSection,
BOT_LOCALES,
} from '../../api/buttonStyles';
import { ChevronDownIcon } from '@/components/icons';
@@ -18,7 +18,10 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default';
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
{ value: 'default', colorClass: 'bg-dark-500' },
{ value: 'primary', colorClass: 'bg-accent-500' },
// 'primary' — синяя кнопка Telegram Bot API: свотч фиксированный телеграм-синий,
// а не bg-accent-500 — акцент темы кабинета перекрашивается (например, в оранжевый)
// и не имеет отношения к цвету реальной кнопки в боте.
{ value: 'primary', colorClass: 'bg-[#54a9eb]' },
{ value: 'success', colorClass: 'bg-success-500' },
{ value: 'danger', colorClass: 'bg-error-500' },
];
@@ -228,7 +231,7 @@ export function ButtonsTab() {
? 'bg-success-500 text-white'
: cfg.style === 'danger'
? 'bg-error-500 text-white'
: 'bg-accent-500 text-on-accent'
: 'bg-[#54a9eb] text-white'
}`}
>
{t(`admin.buttons.styles.${cfg.style}`)}

View File

@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons';
import { isBroadcastInFlight } from '../../utils/broadcastStatus';
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
export function BroadcastStatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export interface BroadcastDeliveryStatsProps {
status: string;
progressPercent: number;
totalCount: number;
sentCount: number;
blockedCount: number;
failedCount: number;
}
/**
* Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько
* не доехало. Используется и на странице рассылки, и на отправке промопредложения.
*/
export function BroadcastDeliveryStats({
status,
progressPercent,
totalCount,
sentCount,
blockedCount,
failedCount,
}: BroadcastDeliveryStatsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{isBroadcastInFlight(status) && (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-2 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="font-medium text-dark-100">{progressPercent.toFixed(1)}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={totalCount}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={sentCount}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={blockedCount}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={failedCount}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
</div>
);
}
export default BroadcastDeliveryStats;

View File

@@ -6,6 +6,7 @@ import {
retrieveLaunchParams,
} from '@telegram-apps/sdk-react';
import { blockButtonClass } from './blocks/buttonStyles';
import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner';
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
@@ -19,24 +20,12 @@ function isTelegramMobile(): boolean {
}
const HAPP_TV_API = 'https://check.happ.su/sendtv';
const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js';
interface Props {
subscriptionUrl: string;
isLight: boolean;
}
interface Html5QrcodeInstance {
start: (
cameraIdOrConfig: { facingMode: string } | string,
config: { fps: number; qrbox: { width: number; height: number } },
onSuccess: (decoded: string) => void,
onError: () => void,
) => Promise<void>;
stop: () => Promise<void>;
clear: () => void;
}
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
const { t } = useTranslation();
const [code, setCode] = useState('');
@@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
return;
}
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
const w = window as WindowWithHtml5;
if (!w.Html5Qrcode) {
const script = document.createElement('script');
script.src = HTML5_QRCODE_CDN;
document.head.appendChild(script);
await new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject();
}).catch(() => undefined);
}
if (!w.Html5Qrcode) {
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode.
// Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой
// целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете.
const Html5Qrcode = await loadHtml5Qrcode();
if (!Html5Qrcode) {
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
return;
}
setScanning(true);
const scanner = new w.Html5Qrcode('tv-qr-reader');
const scanner = new Html5Qrcode('tv-qr-reader');
scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
try {

View File

@@ -8,6 +8,7 @@ import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { usePlatform } from '../../../platform';
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
import { getMonthlyPriceKopeks } from '../../../utils/pricing';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff, TariffPeriod } from '../../../types';
@@ -35,6 +36,8 @@ export interface TariffPurchaseFormProps {
balanceKopeks: number | undefined;
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
sbpPurchaseEnabled?: boolean;
/** Оформление привязкой Lava доступно — показать вторую CTA. */
lavaPurchaseEnabled?: boolean;
onBack: () => void;
}
@@ -43,6 +46,7 @@ export function TariffPurchaseForm({
subscriptionId,
balanceKopeks,
sbpPurchaseEnabled = false,
lavaPurchaseEnabled = false,
onBack,
}: TariffPurchaseFormProps) {
const { t } = useTranslation();
@@ -142,6 +146,47 @@ export function TariffPurchaseForm({
</>
);
const lavaPurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseWithLavaRecurring(tariff.id),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['lava-recurring', data.subscription_id] });
navigate('/subscriptions', { replace: true });
},
});
const lavaPurchaseButton = lavaPurchaseEnabled && (
<>
<button
onClick={() => lavaPurchaseMutation.mutate()}
disabled={lavaPurchaseMutation.isPending || purchaseMutation.isPending}
className="mt-2 w-full rounded-xl border border-accent-500/40 bg-accent-500/10 py-3 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/20 disabled:opacity-50"
>
{lavaPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('common.loading')}
</span>
) : (
t('subscription.lavaRecurring.purchaseButton')
)}
</button>
<div className="mt-1.5 text-center text-[11px] text-dark-500">
{t('subscription.lavaRecurring.purchaseHint')}
</div>
{lavaPurchaseMutation.isError && (
<div className="mt-2 text-center text-sm text-error-400">
{getErrorMessage(lavaPurchaseMutation.error)}
</div>
)}
</>
);
// Smooth scroll the form into view when first mounted.
useEffect(() => {
if (ref.current) {
@@ -240,6 +285,7 @@ export function TariffPurchaseForm({
</button>
{sbpPurchaseButton}
{lavaPurchaseButton}
{purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && (
@@ -279,10 +325,7 @@ export function TariffPurchaseForm({
const displayDiscount = promoPeriod.percent;
const displayOriginal = promoPeriod.original;
const displayPrice = promoPeriod.price;
const displayPerMonth =
displayPrice !== period.price_kopeks
? Math.round(displayPrice / Math.max(1, period.days / 30))
: period.price_per_month_kopeks;
const displayPerMonth = getMonthlyPriceKopeks(displayPrice, period.days);
return (
<button
@@ -317,9 +360,11 @@ export function TariffPurchaseForm({
</span>
)}
</div>
<div className="mt-1 text-xs text-dark-500">
{formatPrice(displayPerMonth)}/{t('subscription.month')}
</div>
{displayPerMonth !== null && (
<div className="mt-1 text-xs text-dark-500">
{formatPrice(displayPerMonth)}/{t('subscription.month')}
</div>
)}
</button>
);
})}
@@ -666,6 +711,7 @@ export function TariffPurchaseForm({
</button>
{sbpPurchaseButton}
{lavaPurchaseButton}
</>
);
})()}

View File

@@ -225,6 +225,10 @@
"invalidCredentials": "Invalid email or password",
"tooManyAttempts": "Too many attempts. Please try again later",
"verificationEmailNotice": "After registration, a verification email will be sent to your address",
"legalConsentPrefix": "I have read the",
"legalConsentTitle": "One more step",
"legalConsentSubtitle": "To create an account, confirm that you have read the documents.",
"legalConsentContinue": "Continue",
"checkEmail": "Check your email",
"verificationSent": "We sent a verification link to:",
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
@@ -701,6 +705,33 @@
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
},
"lavaRecurring": {
"title": "Lava auto-renewal",
"connect": "Connect",
"payFirst": "Pay the first invoice",
"cancel": "Disable",
"confirmCancel": "Disable Lava auto-renewal? The subscription will no longer renew automatically.",
"cancelled": "Lava auto-renewal disabled",
"enableError": "Could not enable Lava auto-renewal",
"cancelError": "Could not disable Lava auto-renewal",
"statusPending": "Waiting for the first invoice to be paid",
"statusActive": "Active",
"statusPastDue": "Payment failed — please try again",
"nextCharge": "next charge on {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "{{amount}} ₽ every {{days}} days",
"period": {
"day": "daily",
"week": "weekly",
"month": "monthly",
"quarter": "quarterly",
"halfYear": "every six months",
"year": "yearly"
},
"autopayHint": "The subscription will renew automatically via Lava",
"purchaseButton": "⚡ Subscribe with Lava auto-renewal",
"purchaseHint": "The first invoice is paid via a Lava link; renewals are then charged automatically on the products schedule."
},
"backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge",
@@ -1930,7 +1961,7 @@
},
"broadcasts": {
"title": "Broadcasts",
"subtitle": "History and management",
"subtitle": "Mass sending of messages to users",
"create": "Create Broadcast",
"empty": "No broadcasts",
"selectFilter": "Select audience",
@@ -2293,7 +2324,15 @@
"offlineConv": "Offline Conversions",
"apiKey": "API Key",
"legalFooter": "Legal Footer",
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page"
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page",
"botStartVideo": "Bot start-menu video",
"botStartVideoDesc": "The bot attaches this video to the /start message instead of the logo image. The file is uploaded to Telegram once — we only keep its identifier.",
"botStartVideoUpload": "Upload video",
"botStartVideoReplace": "Replace video",
"botStartVideoRemove": "Remove",
"botStartVideoActive": "Video is set",
"botStartVideoNone": "No video — the menu uses the logo image",
"botStartVideoError": "Could not upload the video"
},
"bulkActions": {
"title": "Bulk Actions",
@@ -2740,7 +2779,9 @@
"periodsRequired": "Add at least one period",
"dailyPriceRequired": "Enter a price per day greater than 0",
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
}
},
"lavaProductLabel": "Lava product (auto-renewal)",
"lavaProductDesc": "Product UUID from the Lava dashboard: price and charge period are configured there. Empty — auto-renewal is unavailable for this tariff."
},
"servers": {
"title": "Squad Management",
@@ -3131,7 +3172,9 @@
"validDaysHint": "0 or empty — perpetual",
"create": "Create batch",
"creating": "Creating…",
"cancel": "Cancel"
"cancel": "Cancel",
"maxPerUser": "Per-user limit",
"maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests."
},
"validation": {
"nameRequired": "Enter the batch name",
@@ -3156,7 +3199,8 @@
"validUntil": "Valid until",
"perpetual": "Perpetual",
"createdAt": "Created",
"linksTitle": "Active links ({{count}})"
"linksTitle": "Active links ({{count}})",
"maxPerUser": "Per user"
},
"revoke": {
"button": "Revoke unredeemed ({{count}})",
@@ -3169,7 +3213,16 @@
"errors": {
"loadFailed": "Batch not found",
"createFailed": "Failed to create the batch",
"revokeFailed": "Failed to revoke the batch"
"revokeFailed": "Failed to revoke the batch",
"deleteFailed": "Could not delete the batch"
},
"delete": {
"button": "🗑 Delete batch",
"confirmTitle": "Delete the batch?",
"confirmText": "{{count}} coupons will be deleted along with the batch itself.",
"redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.",
"confirm": "Delete",
"success": "Batch deleted ({{count}} coupons)"
}
},
"promocodes": {
@@ -3807,7 +3860,9 @@
"sendError": "Failed to send offer",
"notificationsSent": "Notifications sent",
"offersCreated": "Offers created",
"notificationsFailed": "(failed: {{count}})"
"notificationsFailed": "(failed: {{count}})",
"deliveryTitle": "Telegram delivery",
"openAsBroadcast": "Open as broadcast"
},
"notFound": "Template not found",
"noActiveTemplates": "No active templates",
@@ -5453,7 +5508,15 @@
"shareModalActivateVia": "Activate via bot:",
"shareModalActivateViaCabinet": "Or via website:",
"copyMessage": "Copy message",
"shareToastCopied": "Message copied"
"shareToastCopied": "Message copied",
"qrHint": "Scan to activate the gift",
"scanButton": "📷 Scan QR",
"scanInProgress": "Scanning…",
"scanCancel": "Stop scanning",
"scanDescription": "Point the camera at the gift QR code",
"scanNotRecognized": "No gift code in this QR",
"scanError": "Could not scan",
"scanNoCamera": "Camera unavailable — enter the code manually"
},
"news": {
"title": "News & Updates",

View File

@@ -228,6 +228,10 @@
"sendResetLink": "ارسال لینک بازنشانی",
"tooManyAttempts": "تلاش‌های زیاد. لطفاً بعداً امتحان کنید",
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
"legalConsentPrefix": "من این سند را مطالعه کرده‌ام:",
"legalConsentTitle": "یک قدم دیگر",
"legalConsentSubtitle": "برای ساخت حساب، تأیید کنید که اسناد را مطالعه کرده‌اید.",
"legalConsentContinue": "ادامه",
"continueWithGoogle": "ادامه با Google",
"continueWithYandex": "ادامه با Yandex",
"continueWithDiscord": "ادامه با Discord",
@@ -276,7 +280,7 @@
"traffic": "ترافیک",
"devices": "دستگاه‌ها",
"expiredDate": "منقضی شده",
"activeUntil": "منقضی شده — تا {{date}}"
"activeUntil": "فعال تا"
},
"deviceLimitReached": "برای افزودن دستگاه جدید، دستگاه‌های فعلی را قطع کنید",
"suspended": {
@@ -287,9 +291,9 @@
"showAll": "نمایش همه",
"subscriptions": "اشتراک‌ها",
"connectDevice": "اتصال دستگاه",
"devicesConnectedUnlimited": "{{count}} دستگاه متصل",
"devicesConnectedUnlimited": "{{used}} دستگاه متصل · نامحدود",
"devicesOfMax": "{{used}} از {{max}}",
"maxUsage": "حداکثر",
"maxUsage": "حداکثر {{amount}}",
"remaining": "باقی‌مانده",
"stats": {
"balance": "موجودی",
@@ -298,9 +302,9 @@
"tariff": "تعرفه",
"trafficUsageTitle": "مصرف ترافیک",
"trialOffer": {
"freeDesc": "{{days}} روز رایگان آزمایش کنید بدون پرداخت",
"freeDesc": "وی‌پی‌ان ما را رایگان امتحان کنید: بدون هیچ تعهدی",
"freeTitle": "دوره آزمایشی رایگان",
"paidDesc": "{{days}} روز آزمایشی فقط با {{price}}",
"paidDesc": "با یک اشتراک آزمایشی شروع کنید",
"paidTitle": "دوره آزمایشی پرداختی"
},
"unlimited": "نامحدود",
@@ -458,7 +462,7 @@
"newDeviceLimit": "محدودیت جدید دستگاه",
"reduce": "کاهش",
"reduceDevices": "کاهش تعداد دستگاه‌ها",
"reduceDevicesDescription": حدودیت دستگاه‌های خود را تنظیم کنید. در صورت لزوم ابتدا دستگاه‌های اضافه را قطع کنید",
"reduceDevicesDescription": ی‌توانید تعداد دستگاه‌ها را تا حداقل مجاز در تعرفه کاهش دهید",
"reduceDevicesTitle": "کاهش تعداد دستگاه‌ها",
"reduceUnavailable": "در حال حاضر کاهش تعداد دستگاه‌ها امکان‌پذیر نیست",
"reducing": "در حال کاهش…"
@@ -600,13 +604,40 @@
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند."
},
"lavaRecurring": {
"title": "تمدید خودکار Lava",
"connect": "اتصال",
"payFirst": "پرداخت صورتحساب اول",
"cancel": "غیرفعال‌سازی",
"confirmCancel": "تمدید خودکار Lava غیرفعال شود؟ اشتراک دیگر به‌طور خودکار تمدید نمی‌شود.",
"cancelled": "تمدید خودکار Lava غیرفعال شد",
"enableError": "فعال‌سازی تمدید خودکار Lava ناموفق بود",
"cancelError": "غیرفعال‌سازی تمدید خودکار Lava ناموفق بود",
"statusPending": "در انتظار پرداخت صورتحساب اول",
"statusActive": "فعال",
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
"nextCharge": "برداشت بعدی {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "هر {{days}} روز {{amount}} ₽",
"period": {
"day": "روزانه",
"week": "هفتگی",
"month": "ماهانه",
"quarter": "هر سه ماه",
"halfYear": "هر شش ماه",
"year": "سالانه"
},
"autopayHint": "اشتراک به‌طور خودکار از طریق Lava تمدید می‌شود",
"purchaseButton": "⚡ ثبت‌نام با تمدید خودکار Lava",
"purchaseHint": "صورتحساب اول از طریق لینک Lava پرداخت می‌شود؛ سپس تمدیدها بر اساس دوره محصول به‌طور خودکار کسر می‌شوند."
},
"backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟",
"cta": {
"renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
"activeHint": "اشتراک فعال است — می‌توانید دستگاه‌ها را متصل کرده و از VPN استفاده کنید",
"expiredHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
"trialHint": "شما در دوره آزمایشی هستید — برای دسترسی به همه امکانات به تعرفه پرداختی ارتقا دهید"
"activeHint": "تمدید و تغییر تعرفه",
"expiredHint": "یک تعرفه انتخاب و پرداخت کنید",
"trialHint": "ترافیک و دستگاه بیشتر"
},
"dailyAutoCharge": "برداشت خودکار روزانه",
"defaultName": "اشتراک",
@@ -626,16 +657,16 @@
"statusTrial": "آزمایشی",
"daysShort": "روز",
"expiredBanner": {
"selectTariff": "تعرفه را انتخاب کنید",
"selectTariff": "اشتراک شما منقضی شده است. برای بازیابی دسترسی به VPN، یک تعرفه را از پایین انتخاب کنید.",
"title": "اشتراک منقضی شده است"
},
"trialInfo": {
"description": "{{days}} روز سرویس ما را رایگان امتحان کنید",
"remaining": "{{count}} روز باقی‌مانده",
"description": "شما در حال استفاده از نسخهٔ آزمایشی سرویس هستید. برای دسترسی کامل، یک تعرفه را در پایین انتخاب کنید.",
"remaining": "باقی‌مانده",
"title": "دوره آزمایشی"
},
"trialUpgrade": {
"description": "برای دسترسی به همه امکانات و ادامه استفاده به اشتراک پرداختی ارتقا دهید",
"description": "دوره آزمایشی شما به‌زودی به پایان می‌رسد. برای ادامه استفاده از VPN بدون محدودیت، یک تعرفه مناسب انتخاب کنید.",
"title": "ارتقای اشتراک"
}
},
@@ -1008,7 +1039,7 @@
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.",
"selectSubscription": "ابتدا یک اشتراک انتخاب کنید"
},
"payWithStars": "پرداخت {stars} Stars",
"payWithStars": "پرداخت Stars",
"processingPayment": "در حال پردازش...",
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
@@ -1564,7 +1595,7 @@
},
"broadcasts": {
"title": "نشریات",
"subtitle": "تاریخچه و مدیریت",
"subtitle": "ارسال گروهی پیام به کاربران",
"create": "ایجاد پیام",
"empty": "هیچ نشریه‌ای نیست",
"selectFilter": "انتخاب مخاطبین",
@@ -1652,46 +1683,46 @@
"telegramSection": "ارسال انبوه تلگرام"
},
"pinnedMessages": {
"title": "Pinned Messages",
"subtitle": "Manage pinned messages for users",
"create": "Create",
"empty": "No pinned messages",
"content": "Message text",
"contentPlaceholder": "Enter pinned message text...",
"noContent": "No text",
"media": "Media file",
"addMedia": "Add media",
"uploading": "Uploading...",
"settings": "Settings",
"sendBeforeMenu": "Send before menu",
"sendOnEveryStart": "Send on every start",
"broadcast": "Broadcast",
"broadcastOnCreate": "Broadcast on create",
"broadcastConfirm": "Broadcast pinned message to all active users?",
"broadcastToAll": "Broadcast to all",
"activate": "Activate",
"deactivate": "Deactivate",
"unpin": "Unpin",
"unpinAll": "Unpin for everyone",
"unpinConfirm": "Unpin message for all users?",
"delete": "Delete",
"deleteConfirm": "Delete this pinned message?",
"active": "Active",
"inactive": "Inactive",
"prev": "Previous",
"next": "Next",
"createSuccess": "Pinned message created",
"updateSuccess": "Message updated",
"deleteSuccess": "Message deleted",
"activateSuccess": "Message activated",
"deactivateSuccess": "Message deactivated",
"broadcastSuccess": "Broadcast completed",
"unpinSuccess": "Messages unpinned",
"sentCount": "Sent",
"failedCount": "Failed",
"unpinnedCount": "Unpinned",
"editMessage": "Edit",
"cantDeleteActive": "Cannot delete active message"
"title": "پیام‌های سنجاق‌شده",
"subtitle": "مدیریت پیام‌های سنجاق‌شده برای کاربران",
"create": "ایجاد",
"empty": "هیچ پیام سنجاق‌شده‌ای وجود ندارد",
"content": "متن پیام",
"contentPlaceholder": "متن پیام سنجاق‌شده را وارد کنید...",
"noContent": "بدون متن",
"media": "فایل رسانه",
"addMedia": "افزودن رسانه",
"uploading": "در حال بارگذاری...",
"settings": "تنظیمات",
"sendBeforeMenu": "ارسال قبل از منو",
"sendOnEveryStart": "ارسال در هر بار شروع",
"broadcast": "ارسال همگانی",
"broadcastOnCreate": "ارسال همگانی هنگام ایجاد",
"broadcastConfirm": "پیام سنجاق‌شده برای همه کاربران فعال ارسال شود؟",
"broadcastToAll": "ارسال به همه",
"activate": "فعال‌سازی",
"deactivate": "غیرفعال‌سازی",
"unpin": "برداشتن سنجاق",
"unpinAll": "برداشتن سنجاق برای همه",
"unpinConfirm": "سنجاق پیام برای همه کاربران برداشته شود؟",
"delete": "حذف",
"deleteConfirm": "این پیام سنجاق‌شده حذف شود؟",
"active": "فعال",
"inactive": "غیرفعال",
"prev": "قبلی",
"next": "بعدی",
"createSuccess": "پیام سنجاق‌شده ایجاد شد",
"updateSuccess": "پیام به‌روزرسانی شد",
"deleteSuccess": "پیام حذف شد",
"activateSuccess": "پیام فعال شد",
"deactivateSuccess": "پیام غیرفعال شد",
"broadcastSuccess": "ارسال همگانی تکمیل شد",
"unpinSuccess": "سنجاق پیام‌ها برداشته شد",
"sentCount": "ارسال‌شده",
"failedCount": "ناموفق",
"unpinnedCount": "سنجاق‌برداشته",
"editMessage": "ویرایش",
"cantDeleteActive": "نمی‌توان پیام فعال را حذف کرد"
},
"channelSubscriptions": {
"title": "کانال‌های اجباری",
@@ -1956,7 +1987,15 @@
"offlineConv": "تبدیل‌های آفلاین",
"apiKey": "کلید API",
"legalFooter": "پاورقی حقوقی",
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود"
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود",
"botStartVideo": "ویدیوی منوی شروع ربات",
"botStartVideoDesc": "ربات این ویدیو را به‌جای تصویر لوگو به پیام /start پیوست می‌کند. فایل یک‌بار در تلگرام بارگذاری می‌شود و ما فقط شناسه آن را نگه می‌داریم.",
"botStartVideoUpload": "بارگذاری ویدیو",
"botStartVideoReplace": "جایگزینی ویدیو",
"botStartVideoRemove": "حذف",
"botStartVideoActive": "ویدیو تنظیم شده است",
"botStartVideoNone": "ویدیویی تنظیم نشده — منو از تصویر لوگو استفاده می‌کند",
"botStartVideoError": "بارگذاری ویدیو ناموفق بود"
},
"buttons": {
"color": "رنگ دکمه",
@@ -2005,7 +2044,7 @@
"builtinButtons": "بخش‌های داخلی",
"resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.",
"buttonTextPlaceholder": "متن دکمه",
"customLabelsHint": "متن دکمه برای هر زبان ربات",
"customLabelsHint": "خالی = عنوان پیش‌فرض",
"moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین",
"openIn": "باز کردن در",
@@ -2278,7 +2317,9 @@
"periodsRequired": "حداقل یک دوره اضافه کنید",
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
}
},
"lavaProductLabel": "محصول Lava (تمدید خودکار)",
"lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم می‌شود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست."
},
"servers": {
"title": "مدیریت اسکوادها",
@@ -2625,10 +2666,10 @@
"programEnabled": "برنامه معرفی فعال است",
"programEnabledDesc": "تمام عملکرد ارجاع را فعال می‌کند",
"requisitesText": "متن اطلاعات حساب",
"requisitesTextDesc": "در فرم درخواست همکاری به کاربر نمایش داده می‌شود",
"requisitesTextDesc": "متن سفارشی برای توضیح به کاربران که هنگام برداشت چه اطلاعات پرداختی را وارد کنند",
"requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...",
"withdrawalEnabled": "برداشت مجاز است",
"withdrawalEnabledDesc": "به کاربران اجازه می‌دهد درخواست برداشت بدهند"
"withdrawalEnabledDesc": "اجازه دادن به همکاران برای درخواست برداشت موجودی معرفی"
},
"settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود",
"settingsSection": {
@@ -3305,7 +3346,9 @@
"notificationsFailed": "(ناموفق: {{count}})",
"sendError": "ارسال پیشنهاد ناموفق بود",
"notificationsSent": "اعلان‌های ارسال شده",
"offersCreated": "پیشنهادهای ایجاد شده"
"offersCreated": "پیشنهادهای ایجاد شده",
"deliveryTitle": "تحویل در تلگرام",
"openAsBroadcast": "نمایش به‌عنوان ارسال گروهی"
},
"notFound": "قالب یافت نشد",
"noActiveTemplates": "قالب فعالی وجود ندارد",
@@ -3398,7 +3441,7 @@
"usersOnlineCount": "{{count}} آنلاین"
},
"overview": {
"bandwidth": "پهنای باند لحظه‌ای",
"bandwidth": "ترافیک ورودی‌ها",
"connections": "اتصالات",
"cpu": "هسته‌های CPU",
"download": "دانلود",
@@ -4940,40 +4983,48 @@
},
"activateButton": "فعال‌سازی",
"activateCodePlaceholder": "کد هدیه را وارد کنید",
"activatedBy": "توسط {{name}} فعال شد",
"activatedBy": "توسط {{username}} فعال شد",
"activateDescription": "برای فعال‌سازی اشتراک، کد هدیه دریافتی را وارد کنید",
"activatedGiftsTitle": "هدایای فعال‌شده",
"activateError": "فعال‌سازی هدیه ناموفق بود",
"activateSelfError": "نمی‌توانید هدیه خودتان را فعال کنید",
"activateSuccess": "هدیه فعال شد!",
"activateSuccessDesc": "اشتراک به حساب شما افزوده شد",
"activateSuccessDesc": "{{tariff}}: {{days}} روز به اشتراک شما اضافه شد",
"activateTitle": "فعال‌سازی هدیه",
"activeGiftsTitle": "هدایای فعال",
"codeLabel": "کد هدیه",
"codeReadyTitle": "کد هدیه شما آماده است",
"copyMessage": "کپی پیام",
"daysShort": "روز",
"deviceCount": "تعداد دستگاهها",
"deviceCount": "{{count}} دستگاه",
"devicesShort": "دستگاه",
"gbShort": "گیگابایت",
"myGiftsEmpty": "هنوز هدیه‌ای ندارید",
"myGiftsEmptyDesc": "هدایایی که ارسال یا دریافت می‌کنید اینجا نمایش داده می‌شوند",
"myGiftsEmptyDesc": "برای یک دوست هدیه بخرید یا کد دریافتی را فعال کنید",
"pageTitle": "هدایا",
"receivedGiftsTitle": "هدایای دریافت‌شده",
"selectPeriod": "دوره را انتخاب کنید",
"selectTariff": "تعرفه را انتخاب کنید",
"sentTo": "به {{name}} ارسال شد",
"sentTo": "به {{recipient}} ارسال شد",
"shareGift": "به اشتراک گذاشتن هدیه",
"shareModalActivateVia": "از طریق فعال‌سازی",
"shareModalActivateVia": "فعال‌سازی از طریق ربات:",
"shareModalActivateViaCabinet": "از طریق کابین",
"shareText": "برای شما هدیه‌ای از Bedolaga VPN دارم: {{code}}",
"shareText": "برای تو یک هدیهٔ Bedolaga VPN دارم، از همین‌جا فعالش کن:",
"shareToastCopied": "لینک کپی شد",
"statusActivated": "فعال شده",
"statusAvailable": "قابل فعال‌سازی",
"tabActivate": "فعال‌سازی",
"tabBuy": "خرید",
"tabMyGifts": "هدایای من",
"unlimitedTraffic": "ترافیک نامحدود"
"unlimitedTraffic": "ترافیک نامحدود",
"qrHint": "برای فعال‌سازی هدیه اسکن کنید",
"scanButton": "📷 اسکن QR",
"scanInProgress": "در حال اسکن…",
"scanCancel": "توقف اسکن",
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
"scanNotRecognized": "در این QR کد هدیه‌ای نیست",
"scanError": "اسکن ناموفق بود",
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
},
"news": {
"title": "اخبار و به‌روزرسانی‌ها",

View File

@@ -231,6 +231,10 @@
"sendResetLink": "Отправить ссылку",
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
"legalConsentPrefix": "Я ознакомлен(а) с документом",
"legalConsentTitle": "Ещё один шаг",
"legalConsentSubtitle": "Чтобы создать аккаунт, подтвердите, что ознакомились с документами.",
"legalConsentContinue": "Продолжить",
"checkEmail": "Проверьте почту",
"verificationSent": "Мы отправили ссылку для подтверждения на:",
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
@@ -720,6 +724,33 @@
"purchaseButton": "⚡ Оформить с автооплатой СБП",
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
},
"lavaRecurring": {
"title": "Автопродление Lava",
"connect": "Подключить",
"payFirst": "Оплатить первый счёт",
"cancel": "Отключить",
"confirmCancel": "Отключить автопродление Lava? Подписка больше не будет продлеваться автоматически.",
"cancelled": "Автопродление Lava отключено",
"enableError": "Не удалось подключить автопродление Lava",
"cancelError": "Не удалось отменить автопродление Lava",
"statusPending": "Ожидает оплаты первого счёта",
"statusActive": "Активно",
"statusPastDue": "Платёж не прошёл — попробуйте снова",
"nextCharge": "следующее списание {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "{{amount}} ₽ раз в {{days}} дн.",
"period": {
"day": "ежедневно",
"week": "еженедельно",
"month": "ежемесячно",
"quarter": "раз в квартал",
"halfYear": "раз в полгода",
"year": "ежегодно"
},
"autopayHint": "Подписка будет автоматически продлеваться через Lava",
"purchaseButton": "⚡ Оформить с автооплатой Lava",
"purchaseHint": "Первое списание оплачивается по ссылке Lava; дальше продления списываются автоматически по периоду продукта."
},
"backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание",
@@ -2796,7 +2827,15 @@
"offlineConv": "Офлайн-конверсии",
"apiKey": "API-ключ",
"legalFooter": "Юридический футер",
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа"
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа",
"botStartVideo": "Видео стартового меню бота",
"botStartVideoDesc": "Бот прикрепит это видео к сообщению /start вместо картинки-логотипа. Файл разово отправляется в Telegram — храним только его идентификатор.",
"botStartVideoUpload": "Загрузить видео",
"botStartVideoReplace": "Заменить видео",
"botStartVideoRemove": "Удалить",
"botStartVideoActive": "Видео подключено",
"botStartVideoNone": "Видео не задано — меню с картинкой-логотипом",
"botStartVideoError": "Не удалось загрузить видео"
},
"buttons": {
"color": "Цвет кнопки",
@@ -3129,7 +3168,9 @@
"periodsRequired": "Добавьте хотя бы один период",
"dailyPriceRequired": "Укажите цену за день больше 0",
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
}
},
"lavaProductLabel": "Продукт Lava (автопродление)",
"lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно."
},
"servers": {
"title": "Управление сквадами",
@@ -3523,7 +3564,9 @@
"validDaysHint": "0 или пусто — бессрочно",
"create": "Создать партию",
"creating": "Создание…",
"cancel": "Отмена"
"cancel": "Отмена",
"maxPerUser": "Лимит на пользователя",
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
},
"validation": {
"nameRequired": "Укажите название партии",
@@ -3548,7 +3591,8 @@
"validUntil": "Действует до",
"perpetual": "Бессрочно",
"createdAt": "Создана",
"linksTitle": "Активные ссылки ({{count}} шт.)"
"linksTitle": "Активные ссылки ({{count}} шт.)",
"maxPerUser": "На пользователя"
},
"revoke": {
"button": "Отозвать непогашенные ({{count}})",
@@ -3561,7 +3605,16 @@
"errors": {
"loadFailed": "Партия не найдена",
"createFailed": "Не удалось создать партию",
"revokeFailed": "Не удалось отозвать партию"
"revokeFailed": "Не удалось отозвать партию",
"deleteFailed": "Не удалось удалить партию"
},
"delete": {
"button": "🗑 Удалить партию",
"confirmTitle": "Удалить партию?",
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
"confirm": "Удалить",
"success": "Партия удалена (купонов: {{count}})"
}
},
"promocodes": {
@@ -4208,7 +4261,9 @@
"sendError": "Не удалось отправить предложение",
"notificationsSent": "Отправлено уведомлений",
"offersCreated": "Создано офферов",
"notificationsFailed": "(не отправлено: {{count}})"
"notificationsFailed": "(не отправлено: {{count}})",
"deliveryTitle": "Доставка в Telegram",
"openAsBroadcast": "Открыть как рассылку"
},
"notFound": "Шаблон не найден",
"noActiveTemplates": "Нет активных шаблонов",
@@ -6007,7 +6062,15 @@
"shareModalActivateVia": "Активировать через бота:",
"shareModalActivateViaCabinet": "Или через сайт:",
"copyMessage": "Скопировать сообщение",
"shareToastCopied": "Сообщение скопировано"
"shareToastCopied": "Сообщение скопировано",
"qrHint": "Отсканируйте, чтобы активировать подарок",
"scanButton": "📷 Отсканировать QR",
"scanInProgress": "Сканирование…",
"scanCancel": "Остановить сканирование",
"scanDescription": "Наведите камеру на QR-код подарка",
"scanNotRecognized": "В QR-коде нет кода подарка",
"scanError": "Не удалось отсканировать",
"scanNoCamera": "Камера недоступна — введите код вручную"
},
"news": {
"title": "Новости и обновления",

View File

@@ -20,7 +20,7 @@
"delete": "删除",
"currency": "¥",
"copy": "复制",
"all": "All",
"all": "全部",
"add": "添加",
"refresh": "刷新",
"retry": "重试",
@@ -228,6 +228,10 @@
"sendResetLink": "发送重置链接",
"tooManyAttempts": "尝试次数过多,请稍后再试",
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
"legalConsentPrefix": "我已阅读",
"legalConsentTitle": "还差一步",
"legalConsentSubtitle": "创建账户前,请确认您已阅读以下文件。",
"legalConsentContinue": "继续",
"continueWithGoogle": "通过 Google 继续",
"continueWithYandex": "通过 Yandex 继续",
"continueWithDiscord": "通过 Discord 继续",
@@ -276,7 +280,7 @@
"traffic": "流量",
"devices": "设备",
"expiredDate": "过期时间",
"activeUntil": "已过期 — 至 {{date}}"
"activeUntil": "有效期至"
},
"deviceLimitReached": "断开设备以添加新设备",
"suspended": {
@@ -287,9 +291,9 @@
"showAll": "全部显示",
"subscriptions": "订阅",
"connectDevice": "连接设备",
"devicesConnectedUnlimited": "已连接 {{count}} 台设备",
"devicesConnectedUnlimited": "已连接 {{used}} 台设备 · 无限制",
"devicesOfMax": "{{used}} / {{max}}",
"maxUsage": "最大",
"maxUsage": "最大 {{amount}}",
"remaining": "剩余",
"stats": {
"balance": "余额",
@@ -298,9 +302,9 @@
"tariff": "套餐",
"trafficUsageTitle": "流量使用",
"trialOffer": {
"freeDesc": "免费试用 {{days}} 天 — 无需付款",
"freeDesc": "免费试用我们的 VPN无需任何承诺",
"freeTitle": "免费试用",
"paidDesc": "{{days}} 天试用,仅需 {{price}}",
"paidDesc": "从试用订阅开始吧",
"paidTitle": "付费试用"
},
"unlimited": "无限",
@@ -458,7 +462,7 @@
"newDeviceLimit": "新设备限制",
"reduce": "减少",
"reduceDevices": "减少设备数量",
"reduceDevicesDescription": "调整您的设备限制。如有需要,请先断开多余设备的连接",
"reduceDevicesDescription": "您可以将设备数量减少至套餐的最低限制",
"reduceDevicesTitle": "减少设备数量",
"reduceUnavailable": "当前无法减少设备数量",
"reducing": "正在减少…"
@@ -600,13 +604,40 @@
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
},
"lavaRecurring": {
"title": "Lava 自动续订",
"connect": "连接",
"payFirst": "支付首期账单",
"cancel": "关闭",
"confirmCancel": "要关闭 Lava 自动续订吗?订阅将不再自动续订。",
"cancelled": "已关闭 Lava 自动续订",
"enableError": "无法开启 Lava 自动续订",
"cancelError": "无法关闭 Lava 自动续订",
"statusPending": "等待支付首期账单",
"statusActive": "已启用",
"statusPastDue": "扣款失败 — 请重试",
"nextCharge": "下次扣款 {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "每 {{days}} 天 {{amount}} ₽",
"period": {
"day": "每天",
"week": "每周",
"month": "每月",
"quarter": "每季度",
"halfYear": "每半年",
"year": "每年"
},
"autopayHint": "订阅将通过 Lava 自动续订",
"purchaseButton": "⚡ 使用 Lava 自动续订下单",
"purchaseHint": "首期账单通过 Lava 链接支付;之后按产品周期自动扣款续订。"
},
"backToList": "返回订阅列表",
"confirmDelete": "删除订阅?",
"cta": {
"renewHint": "订阅已过期 — 续订以继续使用",
"activeHint": "订阅有效 — 您可以连接设备并使用 VPN",
"expiredHint": "订阅已过期 — 续订以继续使用",
"trialHint": "您正在试用中 — 升级到付费套餐以解锁所有功能"
"activeHint": "续费和更换套餐",
"expiredHint": "选择套餐并付款",
"trialHint": "更多流量和设备"
},
"dailyAutoCharge": "每日自动扣费",
"defaultName": "订阅",
@@ -626,16 +657,16 @@
"statusTrial": "试用",
"daysShort": "天",
"expiredBanner": {
"selectTariff": "选择套餐",
"selectTariff": "您的订阅已到期。请在下方选择套餐,以恢复 VPN 访问。",
"title": "订阅已过期"
},
"trialInfo": {
"description": "免费试用我们的服务 {{days}} 天",
"remaining": "剩余 {{count}} 天",
"description": "您正在使用服务的试用版。请在下方选择套餐以获得完整访问权限。",
"remaining": "剩余",
"title": "试用期"
},
"trialUpgrade": {
"description": "升级到付费订阅以解锁所有功能并继续使用",
"description": "您的试用期即将结束。请选择合适的套餐,以继续无限制使用 VPN。",
"title": "升级订阅"
}
},
@@ -1012,7 +1043,7 @@
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。",
"selectSubscription": "请先选择订阅"
},
"payWithStars": "支付 {stars} Stars",
"payWithStars": "支付 Stars",
"processingPayment": "处理中...",
"starsPaymentSuccess": "支付成功抽奖结果已发送到Telegram。",
"starsPaymentFailed": "支付失败。请重试。",
@@ -1667,7 +1698,7 @@
},
"broadcasts": {
"title": "广播",
"subtitle": "历史和管理",
"subtitle": "向用户群发消息",
"create": "创建群发",
"empty": "没有广播",
"selectFilter": "选择受众",
@@ -2023,7 +2054,15 @@
"offlineConv": "离线转化",
"apiKey": "API 密钥",
"legalFooter": "法律页脚",
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接"
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接",
"botStartVideo": "机器人开始菜单视频",
"botStartVideoDesc": "机器人会在 /start 消息中附带该视频,替代 logo 图片。文件仅上传到 Telegram 一次,我们只保存其标识符。",
"botStartVideoUpload": "上传视频",
"botStartVideoReplace": "替换视频",
"botStartVideoRemove": "删除",
"botStartVideoActive": "已设置视频",
"botStartVideoNone": "未设置视频 — 菜单使用 logo 图片",
"botStartVideoError": "视频上传失败"
},
"buttons": {
"color": "按钮颜色",
@@ -2072,7 +2111,7 @@
"builtinButtons": "内置栏目",
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
"buttonTextPlaceholder": "按钮文本",
"customLabelsHint": "每种语言的按钮文本",
"customLabelsHint": "留空 = 默认名称",
"moveUp": "上移",
"moveDown": "下移",
"openIn": "打开方式",
@@ -2344,7 +2383,9 @@
"periodsRequired": "请添加至少一个期限",
"dailyPriceRequired": "请输入大于0的每日价格",
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
}
},
"lavaProductLabel": "Lava 产品(自动续订)",
"lavaProductDesc": "来自 Lava 后台的产品 UUID价格与扣款周期在那里设置。留空则该套餐不支持自动续订。"
},
"servers": {
"title": "服务器管理",
@@ -2624,10 +2665,10 @@
"programEnabled": "推荐计划已启用",
"programEnabledDesc": "启用整个推荐人功能",
"requisitesText": "收款信息文本",
"requisitesTextDesc": "将在合作申请表单中向用户显示",
"requisitesTextDesc": "自定义提示,告知用户提现时需填写的收款信息",
"requisitesTextPlaceholder": "请输入您的收款信息...",
"withdrawalEnabled": "允许提款",
"withdrawalEnabledDesc": "允许用户请求提款"
"withdrawalEnabledDesc": "允许合作伙伴申请提现推荐余额"
},
"settingsLoadError": "加载推荐设置失败",
"settingsSection": {
@@ -3304,7 +3345,9 @@
"notificationsFailed": "(失败: {{count}}",
"sendError": "发送优惠失败",
"notificationsSent": "已发送通知",
"offersCreated": "已创建优惠"
"offersCreated": "已创建优惠",
"deliveryTitle": "Telegram 送达情况",
"openAsBroadcast": "作为群发查看"
},
"notFound": "未找到模板",
"noActiveTemplates": "没有活跃的模板",
@@ -3406,7 +3449,7 @@
"usersOnlineCount": "{{count}} 在线"
},
"overview": {
"bandwidth": "实时带宽",
"bandwidth": "各 inbound 流量",
"connections": "连接数",
"cpu": "CPU核心",
"download": "下载",
@@ -4939,40 +4982,48 @@
},
"activateButton": "激活",
"activateCodePlaceholder": "输入礼物代码",
"activatedBy": "由 {{name}} 激活",
"activatedBy": "由 {{username}} 激活",
"activateDescription": "输入您收到的礼物代码以激活订阅",
"activatedGiftsTitle": "已激活的礼物",
"activateError": "激活礼物失败",
"activateSelfError": "您不能激活自己的礼物",
"activateSuccess": "礼物已激活!",
"activateSuccessDesc": "订阅添加到您的账户",
"activateSuccessDesc": "{{tariff}}:已为您的订阅添加 {{days}} 天",
"activateTitle": "激活礼物",
"activeGiftsTitle": "活跃的礼物",
"codeLabel": "礼物代码",
"codeReadyTitle": "您的礼物代码已就绪",
"copyMessage": "复制消息",
"daysShort": "天",
"deviceCount": "设备",
"deviceCount": "{{count}} 台设备",
"devicesShort": "台",
"gbShort": "GB",
"myGiftsEmpty": "暂无礼物",
"myGiftsEmptyDesc": "您发送或收到的礼物将显示在这里",
"myGiftsEmptyDesc": "为好友购买礼物,或激活已收到的兑换码",
"pageTitle": "礼物",
"receivedGiftsTitle": "已收到的礼物",
"selectPeriod": "选择时长",
"selectTariff": "选择套餐",
"sentTo": "已发送给 {{name}}",
"sentTo": "已发送给 {{recipient}}",
"shareGift": "分享礼物",
"shareModalActivateVia": "通过以下方式激活",
"shareModalActivateVia": "通过机器人激活",
"shareModalActivateViaCabinet": "通过个人中心",
"shareText": "我为您准备了一份 Bedolaga VPN 礼物{{code}}",
"shareText": "我给你准备了一份 Bedolaga VPN 礼物,在这里激活它:",
"shareToastCopied": "链接已复制",
"statusActivated": "已激活",
"statusAvailable": "可激活",
"tabActivate": "激活",
"tabBuy": "购买",
"tabMyGifts": "我的礼物",
"unlimitedTraffic": "无限流量"
"unlimitedTraffic": "无限流量",
"qrHint": "扫描以激活礼物",
"scanButton": "📷 扫描二维码",
"scanInProgress": "扫描中…",
"scanCancel": "停止扫描",
"scanDescription": "将摄像头对准礼物二维码",
"scanNotRecognized": "该二维码中没有礼物码",
"scanError": "扫描失败",
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
},
"news": {
"title": "新闻与更新",

View File

@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { StatCard } from '@/components/stats';
import {
BanIcon,
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
import {
DocumentIcon,
EmailIcon,
PhotoIcon,
RefreshIcon,
SendIcon,
StopIcon,
TelegramIcon,
UsersIcon,
VideoIcon,
XCircleIcon,
} from '@/components/icons';
// Channel badge component
@@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
);
}
// Status badge component
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export default function AdminBroadcastDetail() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
return adminBroadcastsApi.get(broadcastId);
},
enabled: !!broadcastId && !isNaN(broadcastId),
refetchInterval: (query) => {
const data = query.state.data;
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
return 3000;
}
return false;
},
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
});
// Stop mutation
@@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() {
},
});
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
const isRunning = broadcast && isBroadcastInFlight(broadcast.status);
if (!broadcastId || isNaN(broadcastId)) {
navigate('/admin/broadcasts');
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
<h1 className="text-xl font-bold text-dark-100">
{t('admin.broadcasts.detail')} #{broadcast.id}
</h1>
<StatusBadge status={broadcast.status} />
<BroadcastStatusBadge status={broadcast.status} />
<ChannelBadge channel={broadcast.channel} />
</div>
<p className="text-sm text-dark-400">
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
</button>
</div>
{/* Progress */}
{isRunning && (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-2 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="font-medium text-dark-100">
{broadcast.progress_percent.toFixed(1)}%
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={broadcast.total_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={broadcast.sent_count}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={broadcast.blocked_count}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={broadcast.failed_count}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
{/* Progress + stats */}
<BroadcastDeliveryStats
status={broadcast.status}
progressPercent={broadcast.progress_percent}
totalCount={broadcast.total_count}
sentCount={broadcast.sent_count}
blockedCount={broadcast.blocked_count}
failedCount={broadcast.failed_count}
/>
{/* Target */}
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { createNumberInputHandler } from '../utils/inputHelpers';
import { couponsApi, CouponBatchCreated } from '../api/coupons';
import { couponsApi, type CouponBatchCreated } from '../api/coupons';
import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>('');
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null);
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
coupons_count: Number(couponsCount),
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
valid_days: validDays === '' ? 0 : Number(validDays),
max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser),
});
};
@@ -275,6 +277,21 @@ export default function AdminCouponCreate() {
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.maxPerUser')}
</label>
<input
type="number"
value={maxPerUser}
onChange={createNumberInputHandler(setMaxPerUser, 0, 500)}
min={0}
max={500}
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.maxPerUserHint')}</p>
</div>
</div>
{/* Actions */}

View File

@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
const batchId = Number(id);
const [revokeConfirm, setRevokeConfirm] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [copied, setCopied] = useState(false);
const { data: batch, isLoading } = useQuery({
@@ -70,6 +71,28 @@ export default function AdminCouponDetail() {
},
});
const deleteMutation = useMutation({
mutationFn: () => couponsApi.deleteBatch(batchId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
setDeleteConfirm(false);
showToast({
type: 'success',
title: t('admin.coupons.delete.success', { count: result.deleted_coupons }),
message: '',
});
navigate('/admin/coupons', { replace: true });
},
onError: (err) => {
setDeleteConfirm(false);
showToast({
type: 'error',
title: t('admin.coupons.errors.deleteFailed'),
message: getApiErrorMessage(err, ''),
});
},
});
const handleCopyAll = () => {
if (!links) return;
void copyToClipboard(links.links.join('\n'));
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
</span>
</div>
)}
{batch.max_per_user > 0 && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.maxPerUser')}</span>
<span className="text-dark-100">{batch.max_per_user}</span>
</div>
)}
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100">
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
</PermissionGate>
)}
{/* Delete — полностью убирает партию, в отличие от отзыва */}
<PermissionGate permission="coupons:edit" fallback={null}>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full rounded-lg border border-error-500/30 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
>
{t('admin.coupons.delete.button')}
</button>
</PermissionGate>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.coupons.delete.confirmTitle')}
</h3>
<p className="mb-2 text-dark-400">
{t('admin.coupons.delete.confirmText', { count: batch.coupons_total })}
</p>
{batch.redeemed_count > 0 && (
<p className="mb-4 text-sm text-warning-400">
{t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })}
</p>
)}
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(false)} className="btn-secondary flex-1">
{t('admin.coupons.revoke.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
deleteMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('admin.coupons.delete.confirm')}
</button>
</div>
</div>
</div>
)}
{/* Revoke confirmation */}
{revokeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">

View File

@@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
promoOffersApi,
PromoOfferBroadcastRequest,
type PromoOfferBroadcastRequest,
TARGET_SEGMENTS,
TargetSegment,
type TargetSegment,
OFFER_TYPE_CONFIG,
OfferType,
type OfferType,
} from '../api/promoOffers';
import { adminUsersApi, UserListItem } from '../api/adminUsers';
import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { adminUsersApi, type UserListItem } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin';
import {
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval } from '../utils/broadcastStatus';
import {
SendIcon,
CheckIcon,
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
title: string;
message: string;
isSuccess: boolean;
broadcastId?: number | null;
} | null>(null);
// Query templates
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
queryFn: promoOffersApi.getTemplates,
});
// Recipient counts per segment — админ видит охват до отправки
const { data: segmentsData } = useQuery({
queryKey: ['admin-promo-segments'],
queryFn: promoOffersApi.getSegments,
staleTime: 60000,
});
const segmentCounts = new Map(
(segmentsData?.segments || []).map((segment) => [segment.key, segment.count]),
);
const selectedSegmentCount = segmentCounts.get(selectedTarget);
// Delivery progress of the offer we have just sent
const broadcastId = result?.broadcastId ?? null;
const { data: delivery } = useQuery({
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
queryFn: async () => adminBroadcastsApi.get(broadcastId as number),
enabled: broadcastId !== null,
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
});
const templates = templatesData?.items || [];
const activeTemplates = templates.filter((t) => t.is_active);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
if (data.notifications_sent > 0 || data.notifications_failed > 0) {
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
title: t('admin.promoOffers.result.sentTitle'),
message,
isSuccess: true,
broadcastId: data.broadcast_id,
});
},
onError: (error: unknown) => {
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
if (result) {
return (
<div className="animate-fade-in">
<div className="mx-auto max-w-md py-12 text-center">
<div className="mx-auto max-w-2xl py-12 text-center">
<div
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
</div>
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
<p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p>
{/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */}
{delivery && (
<div className="mb-6 space-y-4 text-left">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-dark-300">
{t('admin.promoOffers.result.deliveryTitle')}
</span>
<BroadcastStatusBadge status={delivery.status} />
</div>
<BroadcastDeliveryStats
status={delivery.status}
progressPercent={delivery.progress_percent}
totalCount={delivery.total_count}
sentCount={delivery.sent_count}
blockedCount={delivery.blocked_count}
failedCount={delivery.failed_count}
/>
<button
onClick={() => navigate(`/admin/broadcasts/${delivery.id}`)}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.promoOffers.result.openAsBroadcast')}
</button>
</div>
)}
<div className="flex justify-center gap-3">
<button
onClick={() => navigate('/admin/promo-offers')}
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
</div>
{sendMode === 'segment' ? (
<select
value={selectedTarget}
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="input"
>
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
<option key={key} value={key}>
{t(labelKey)}
</option>
))}
</select>
<>
<select
value={selectedTarget}
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="input"
>
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
const count = segmentCounts.get(key);
return (
<option key={key} value={key}>
{count === undefined
? t(labelKey)
: `${t(labelKey)}${count} ${t('admin.broadcasts.recipients')}`}
</option>
);
})}
</select>
{selectedSegmentCount !== undefined && (
<div className="mt-2 text-sm text-dark-400">
{t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{selectedSegmentCount}</strong>
</div>
)}
</>
) : (
<div ref={searchRef} className="relative">
{selectedUser ? (

View File

@@ -50,6 +50,7 @@ export default function AdminTariffCreate() {
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
const [lavaProductId, setLavaProductId] = useState('');
// Traffic topup
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
@@ -122,6 +123,7 @@ export default function AdminTariffCreate() {
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
);
setDailyPriceKopeks(data.daily_price_kopeks || 0);
setLavaProductId(data.lava_product_id || '');
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
setTrafficTopupPackages(data.traffic_topup_packages || {});
@@ -176,6 +178,8 @@ export default function AdminTariffCreate() {
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
is_daily: isDaily,
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
// Пустая строка отвязывает тариф от продукта Lava
lava_product_id: lavaProductId.trim(),
traffic_reset_mode: trafficResetMode,
};
@@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
</div>
)}
{/* Lava recurrent product */}
<div>
<label
htmlFor="tariff-lava-product"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.tariffs.lavaProductLabel')}
</label>
<input
id="tariff-lava-product"
type="text"
value={lavaProductId}
onChange={(e) => setLavaProductId(e.target.value)}
className="input w-full"
placeholder="6be21df9-0bcd-44ac-9c2c-3be7bc94decc"
/>
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.lavaProductDesc')}</p>
</div>
{/* Traffic Limit */}
<div>
<label

View File

@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { giftApi } from '../api/gift';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { Spinner } from '@/components/ui/Spinner';
@@ -121,6 +122,14 @@ function CodeOnlySuccessState({
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
</div>
{/* QR — получателю проще отсканировать, чем копировать ссылку.
Кодируем bot-ссылку, если она есть: активация в боте — основной путь,
иначе ссылку кабинета. */}
<div className="flex w-full flex-col items-center gap-2 rounded-xl border border-dark-700/30 bg-white p-4">
<QRCodeSVG value={botLink ?? cabinetLink} size={180} level="M" includeMargin={false} />
</div>
<p className="-mt-3 text-xs text-dark-400">{t('gift.qrHint', 'Scan to activate the gift')}</p>
{/* Share message preview */}
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
<p className="mb-3 text-sm font-medium text-dark-100">

View File

@@ -1,11 +1,18 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate, useSearchParams, Link } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { giftApi } from '../api/gift';
import {
canUseTelegramScanner,
loadHtml5Qrcode,
parseGiftCode,
scanWithTelegram,
type Html5QrcodeInstance,
} from '@/utils/qrScanner';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import type {
GiftConfig,
@@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
if (initialCode) setCode(initialCode);
}, [initialCode]);
const [activateError, setActivateError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const scannerRef = useRef<Html5QrcodeInstance | null>(null);
const stopScan = useCallback(() => {
if (scannerRef.current) {
scannerRef.current
.stop()
.catch(() => undefined)
.finally(() => {
scannerRef.current?.clear();
scannerRef.current = null;
});
}
setScanning(false);
}, []);
// Веб-сканер держит камеру: гасим его при уходе с вкладки/размонтировании,
// иначе индикатор камеры остаётся гореть.
useEffect(() => stopScan, [stopScan]);
const applyScannedCode = useCallback(
(decoded: string) => {
const parsed = parseGiftCode(decoded);
if (!parsed) {
setActivateError(t('gift.scanNotRecognized'));
return;
}
setCode(parsed);
setActivateError(null);
},
[t],
);
const handleScan = useCallback(async () => {
setActivateError(null);
if (canUseTelegramScanner()) {
try {
const decoded = await scanWithTelegram(
t('gift.scanDescription'),
(v) => parseGiftCode(v) !== null,
);
if (decoded) applyScannedCode(decoded);
} catch {
setActivateError(t('gift.scanError'));
}
return;
}
const Html5Qrcode = await loadHtml5Qrcode();
if (!Html5Qrcode) {
setActivateError(t('gift.scanNoCamera'));
return;
}
setScanning(true);
const scanner = new Html5Qrcode('gift-qr-reader');
scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
const onDecoded = (decoded: string) => {
if (parseGiftCode(decoded) === null) return;
stopScan();
applyScannedCode(decoded);
};
try {
await scanner.start({ facingMode: 'environment' }, config, onDecoded, () => undefined);
} catch {
try {
await scanner.start({ facingMode: 'user' }, config, onDecoded, () => undefined);
} catch {
setActivateError(t('gift.scanNoCamera'));
scannerRef.current = null;
setScanning(false);
}
}
}, [applyScannedCode, stopScan, t]);
const activateMutation = useMutation({
mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode),
@@ -810,6 +893,32 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
aria-label={t('gift.activateTitle')}
/>
{/* Скан QR: в Telegram — нативный сканер (в WebView камера через
getUserMedia работает ненадёжно), в вебе — html5-qrcode. */}
<button
type="button"
onClick={handleScan}
disabled={scanning}
className="mt-3 w-full rounded-2xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50 disabled:opacity-50"
>
{scanning ? t('gift.scanInProgress') : t('gift.scanButton')}
</button>
{/* Контейнер веб-сканера: html5-qrcode рендерит превью камеры внутрь */}
<div
id="gift-qr-reader"
className={cn('mt-3 overflow-hidden rounded-2xl', !scanning && 'hidden')}
/>
{scanning && (
<button
type="button"
onClick={stopScan}
className="mt-2 w-full rounded-2xl border border-dark-700/50 px-6 py-2 text-xs text-dark-400 transition-colors hover:bg-dark-800/50"
>
{t('gift.scanCancel')}
</button>
)}
</div>
{/* Error */}

View File

@@ -26,9 +26,12 @@ import { saveOAuthState } from '../utils/oauth';
import { getPendingReferralCode } from '../utils/referral';
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
import LegalFooter from '../components/LegalFooter';
import LegalConsent from '../components/LegalConsent';
import { infoApi } from '../api/info';
import type { LegalConsentConfig } from '../types';
export default function Login() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const {
@@ -69,6 +72,70 @@ export default function Login() {
const [forgotPasswordError, setForgotPasswordError] = useState('');
const [showEmailForm, setShowEmailForm] = useState(true);
// Гейт согласия с офертой/политикой для НОВОГО пользователя. Конфиг публичный:
// нужен до авторизации, чтобы нарисовать чекбоксы ещё на экране входа.
const { data: legalConsent } = useQuery<LegalConsentConfig>({
queryKey: ['legal-consent-config', i18n.language],
queryFn: () => infoApi.getLegalConsentConfig(i18n.language),
staleTime: 5 * 60 * 1000,
retry: false,
});
const consentDocuments = useMemo(() => legalConsent?.documents ?? [], [legalConsent]);
const [acceptedDocuments, setAcceptedDocuments] = useState<Record<string, boolean>>({});
// Telegram-вход происходит сам собой, поэтому чекбоксы показываем только когда
// бэк ответил 428: пользователь новый и без согласия аккаунт не создастся.
// Замыкание помнит, какой именно вход повторить после простановки галочек.
const [pendingConsentRetry, setPendingConsentRetry] = useState<
((accepted: string[]) => Promise<void>) | null
>(null);
useEffect(() => {
if (!legalConsent?.prechecked || consentDocuments.length === 0) return;
setAcceptedDocuments((prev) => {
const next = { ...prev };
for (const document of consentDocuments) {
if (next[document] === undefined) next[document] = true;
}
return next;
});
}, [legalConsent?.prechecked, consentDocuments]);
const acceptedDocumentKeys = useMemo(
() => consentDocuments.filter((document) => acceptedDocuments[document]),
[consentDocuments, acceptedDocuments],
);
const allDocumentsAccepted =
consentDocuments.length === 0 || acceptedDocumentKeys.length === consentDocuments.length;
const toggleDocument = useCallback((document: string, value: boolean) => {
setAcceptedDocuments((prev) => ({ ...prev, [document]: value }));
}, []);
// 428 = бэк требует согласие. Запоминаем, что повторить, и рисуем чекбоксы.
const captureConsentRequirement = useCallback(
(err: unknown, retry: (accepted: string[]) => Promise<void>): boolean => {
const error = err as { response?: { status?: number; data?: { detail?: unknown } } };
if (error.response?.status !== 428) return false;
const detail = error.response?.data?.detail as
| { documents?: string[]; prechecked?: boolean }
| undefined;
if (detail?.documents?.length) {
const documents = detail.documents;
setAcceptedDocuments((prev) => {
const next = { ...prev };
for (const document of documents) {
if (next[document] === undefined) next[document] = Boolean(detail.prechecked);
}
return next;
});
}
setPendingConsentRetry(() => retry);
return true;
},
[],
);
// Telegram safe area insets
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
@@ -197,6 +264,16 @@ export default function Login() {
if (import.meta.env.DEV)
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
// Не ошибка входа, а недостающее согласие: показываем чекбоксы.
const needsConsent = captureConsentRequirement(err, async (accepted) => {
await loginWithTelegram(initData, accepted);
navigate(getReturnUrl(), { replace: true });
});
if (needsConsent) {
setIsLoading(false);
return;
}
if (status === 401 && attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, 1500));
continue;
@@ -211,7 +288,7 @@ export default function Login() {
};
tryTelegramAuth();
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
const handleRetryTelegramAuth = () => {
// Clear ALL cached auth state to prevent stale token/initData loops
@@ -264,6 +341,7 @@ export default function Login() {
password,
firstName || undefined,
referralCode || undefined,
acceptedDocumentKeys,
);
// Show "check your email" screen
setRegisteredEmail(result.email);
@@ -273,6 +351,24 @@ export default function Login() {
const status = error.response?.status;
const detail = getApiErrorMessage(err, '');
// Конфиг чекбоксов мог протухнуть (админ включил гейт между загрузкой страницы
// и отправкой формы) — показываем недостающие галочки вместо сырой ошибки.
const needsConsent = captureConsentRequirement(err, async (accepted) => {
const retried = await registerWithEmail(
email,
password,
firstName || undefined,
referralCode || undefined,
accepted,
);
setPendingConsentRetry(null);
setRegisteredEmail(retried.email);
});
if (needsConsent) {
setIsLoading(false);
return;
}
if (status === 400 && detail.includes('already registered')) {
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
} else if (status === 401 || status === 403) {
@@ -376,8 +472,56 @@ export default function Login() {
)}
</div>
{/* Check Email Screen */}
{registeredEmail ? (
{/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */}
{pendingConsentRetry ? (
<div className="card">
<h2 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.legalConsentTitle', 'Ещё один шаг')}
</h2>
<p className="mb-4 text-sm text-dark-400">
{t(
'auth.legalConsentSubtitle',
'Чтобы создать аккаунт, подтвердите, что ознакомились с документами.',
)}
</p>
<LegalConsent
documents={consentDocuments}
accepted={acceptedDocuments}
onChange={toggleDocument}
disabled={isLoading}
/>
{error && (
<p className="mt-4 text-sm text-error-400" role="alert">
{error}
</p>
)}
<button
type="button"
className="btn-primary mt-5 w-full"
disabled={!allDocumentsAccepted || isLoading}
onClick={async () => {
setError('');
setIsLoading(true);
try {
await pendingConsentRetry(acceptedDocumentKeys);
setPendingConsentRetry(null);
} catch (err) {
setError(getApiErrorMessage(err, t('common.error')));
} finally {
setIsLoading(false);
}
}}
>
{isLoading
? t('common.loading', 'Загрузка...')
: t('auth.legalConsentContinue', 'Продолжить')}
</button>
</div>
) : /* Check Email Screen */
registeredEmail ? (
<div className="card text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
<EmailIcon className="h-7 w-7 text-success-400" />
@@ -696,9 +840,21 @@ export default function Login() {
</div>
)}
{authMode === 'register' && (
<LegalConsent
documents={consentDocuments}
accepted={acceptedDocuments}
onChange={toggleDocument}
disabled={isLoading}
className="pt-1"
/>
)}
<button
type="submit"
disabled={isLoading}
disabled={
isLoading || (authMode === 'register' && !allDocumentsAccepted)
}
className="btn-primary w-full py-2.5"
>
{isLoading ? (

View File

@@ -5,6 +5,7 @@ import { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme';
import { getMonthlyPriceKopeks } from '../utils/pricing';
import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '../platform';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
@@ -143,8 +144,7 @@ export default function RenewSubscription() {
{options.map((option) => {
const isSelected = selectedPeriod === option.period_days;
const canAfford = balanceKopeks >= option.price_kopeks;
const months = Math.max(1, Math.round(option.period_days / 30));
const perMonth = option.price_kopeks / months;
const perMonth = getMonthlyPriceKopeks(option.price_kopeks, option.period_days);
return (
<button
@@ -167,7 +167,7 @@ export default function RenewSubscription() {
<div className="flex items-center justify-between">
<div>
<span className="text-base font-semibold" style={{ color: g.text }}>
{option.period_days} {t('common.units.days', 'дней')}
{option.period_days} {t('subscription.days', 'дней')}
</span>
{option.discount_percent > 0 && (
<span className="ml-2 rounded-full bg-success-400/15 px-2 py-0.5 text-[10px] font-semibold text-success-400">
@@ -181,10 +181,10 @@ export default function RenewSubscription() {
? t('subscription.free', 'Бесплатно')
: `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`}
</div>
{months > 1 && (
{perMonth !== null && (
<div className="text-[11px]" style={{ color: g.textSecondary }}>
{formatAmount(perMonth / 100)} {currencySymbol}/
{t('common.units.mo', 'мес')}
{t('subscription.month', 'мес')}
</div>
)}
{option.original_price_kopeks && (

View File

@@ -43,6 +43,12 @@ import {
sbpUiState,
type SbpUiState,
} from '../utils/sbpRecurring';
import {
isLavaFeatureDisabledError,
lavaPeriodLabelKey,
lavaUiState,
type LavaUiState,
} from '../utils/lavaRecurring';
import Twemoji from 'react-twemoji';
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
@@ -375,6 +381,78 @@ export default function Subscription() {
cancelSbpMutation.mutate();
};
// Автопродление Lava — независимый от Platega движок с той же семантикой
// состояний. Поллинг раз в 8с, пока привязка PENDING (ждём оплату первого
// счёта), чтобы UI сам перешёл в active/past_due.
const lavaQuery = useQuery({
queryKey: ['lava-recurring', subscriptionId],
queryFn: () => subscriptionApi.getLavaRecurring(subscriptionId),
enabled: !!subscription && !subscription.is_trial,
retry: false,
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
});
const lavaInfo = lavaQuery.data;
const lavaFeatureDisabled = isLavaFeatureDisabledError(lavaQuery.error);
const lavaUiStateValue: LavaUiState =
lavaInfo !== undefined || lavaFeatureDisabled
? lavaUiState(lavaInfo, lavaFeatureDisabled)
: 'hidden';
const enableLavaMutation = useMutation({
mutationFn: () => subscriptionApi.enableLavaRecurring(subscriptionId),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
// Бэкенд снимает autopay_enabled при включении рекуррента провайдера.
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
},
onError: (error: unknown) => {
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
?.detail;
showToast({
type: 'error',
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.enableError'),
message: '',
duration: 3000,
});
},
});
const cancelLavaMutation = useMutation({
mutationFn: () => subscriptionApi.cancelLavaRecurring(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
showToast({
type: 'success',
title: t('subscription.lavaRecurring.cancelled'),
message: '',
duration: 3000,
});
},
onError: (error: unknown) => {
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
?.detail;
showToast({
type: 'error',
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.cancelError'),
message: '',
duration: 3000,
});
},
});
const handleCancelLava = async () => {
const confirmed = await destructiveConfirm(
t('subscription.lavaRecurring.confirmCancel'),
t('subscription.lavaRecurring.cancel'),
);
if (!confirmed) return;
cancelLavaMutation.mutate();
};
const autopayMutation = useMutation({
mutationFn: (enabled: boolean) =>
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
@@ -1280,6 +1358,128 @@ export default function Subscription() {
</div>
</div>
)}
{/* ─── Автопродление Lava ───
Независимый от Platega движок: сиблинг того же тоггла, те же
состояния. Период задан продуктом в кабинете Lava и приезжает
числом дней, поэтому подпись строится из charge_days. */}
{!subscription.is_trial && lavaUiStateValue !== 'hidden' && (
<div
className="mt-3 rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-dark-50">
{t('subscription.lavaRecurring.title')}
</div>
{lavaUiStateValue === 'off' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.autopayHint')}
</div>
)}
{lavaUiStateValue === 'pending' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.statusPending')}
</div>
)}
{lavaUiStateValue === 'active' && lavaInfo && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{(() => {
const periodKey = lavaPeriodLabelKey(lavaInfo.charge_days);
const amount = formatAmount((lavaInfo.amount_kopeks ?? 0) / 100);
return periodKey
? t('subscription.lavaRecurring.amountPerPeriod', {
amount,
period: t(periodKey),
})
: t('subscription.lavaRecurring.amountPerDays', {
amount,
days: lavaInfo.charge_days ?? 0,
});
})()}
</div>
{lavaInfo.next_charge_at && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.nextCharge', {
date: new Date(lavaInfo.next_charge_at).toLocaleDateString(
uiLocale(),
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
),
})}
</div>
)}
</>
)}
{lavaUiStateValue === 'past_due' && (
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
{t('subscription.lavaRecurring.statusPastDue')}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:items-end">
{lavaUiStateValue === 'off' && (
<button
onClick={() => enableLavaMutation.mutate()}
disabled={enableLavaMutation.isPending}
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity disabled:opacity-50 sm:w-auto"
>
{enableLavaMutation.isPending ? (
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
t('subscription.lavaRecurring.connect')
)}
</button>
)}
{lavaUiStateValue === 'pending' && (
<>
{lavaInfo?.redirect_url && (
<button
onClick={() => {
if (lavaInfo.redirect_url) {
openPaymentUrl(lavaInfo.redirect_url, platform, openLink);
}
}}
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity sm:w-auto"
>
{t('subscription.lavaRecurring.payFirst')}
</button>
)}
<button
onClick={handleCancelLava}
disabled={cancelLavaMutation.isPending}
className="text-[11px] font-medium transition-colors disabled:opacity-50 sm:text-right"
style={{ color: 'rgb(var(--color-critical-500))' }}
>
{t('subscription.lavaRecurring.cancel')}
</button>
</>
)}
{(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && (
<button
onClick={handleCancelLava}
disabled={cancelLavaMutation.isPending}
className="w-full whitespace-nowrap rounded-xl border border-error-500/30 bg-error-500/10 px-5 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50 sm:w-auto"
>
{t('subscription.lavaRecurring.cancel')}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
})()

View File

@@ -292,6 +292,12 @@ export default function SubscriptionPurchase() {
'platega_recurrent_enabled' in purchaseOptions &&
purchaseOptions.platega_recurrent_enabled === true
}
lavaPurchaseEnabled={
isTariffsMode &&
purchaseOptions !== undefined &&
'lava_recurrent_enabled' in purchaseOptions &&
purchaseOptions.lava_recurrent_enabled === true
}
onBack={() => {
setShowTariffPurchase(false);
setSelectedTariff(null);

View File

@@ -48,9 +48,12 @@ interface AuthState {
initialize: () => Promise<void>;
refreshUser: () => Promise<void>;
checkAdminStatus: () => Promise<void>;
loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
loginWithTelegram: (initData: string, acceptedLegalDocuments?: string[]) => Promise<void>;
loginWithTelegramWidget: (
data: TelegramWidgetData,
acceptedLegalDocuments?: string[],
) => Promise<void>;
loginWithTelegramOIDC: (idToken: string, acceptedLegalDocuments?: string[]) => Promise<void>;
loginWithEmail: (email: string, password: string) => Promise<void>;
loginWithOAuth: (
provider: string,
@@ -64,6 +67,7 @@ interface AuthState {
password: string,
firstName?: string,
referralCode?: string,
acceptedLegalDocuments?: string[],
) => Promise<RegisterResponse>;
}
@@ -261,10 +265,15 @@ export const useAuthStore = create<AuthState>()(
return initState.promise;
},
loginWithTelegram: async (initData) => {
loginWithTelegram: async (initData, acceptedLegalDocuments) => {
const campaignSlug = getPendingCampaignSlug();
const referralCode = getPendingReferralCode();
const response = await authApi.loginTelegram(initData, campaignSlug, referralCode);
const response = await authApi.loginTelegram(
initData,
campaignSlug,
referralCode,
acceptedLegalDocuments,
);
// Clear only after successful auth — retry keeps the slugs
consumeCampaignSlug();
consumeReferralCode();
@@ -279,10 +288,15 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
loginWithTelegramWidget: async (data) => {
loginWithTelegramWidget: async (data, acceptedLegalDocuments) => {
const campaignSlug = getPendingCampaignSlug();
const referralCode = getPendingReferralCode();
const response = await authApi.loginTelegramWidget(data, campaignSlug, referralCode);
const response = await authApi.loginTelegramWidget(
data,
campaignSlug,
referralCode,
acceptedLegalDocuments,
);
consumeCampaignSlug();
consumeReferralCode();
tokenStorage.setTokens(response.access_token, response.refresh_token);
@@ -296,10 +310,15 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
loginWithTelegramOIDC: async (idToken) => {
loginWithTelegramOIDC: async (idToken, acceptedLegalDocuments) => {
const campaignSlug = getPendingCampaignSlug();
const referralCode = getPendingReferralCode();
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
const response = await authApi.loginTelegramOIDC(
idToken,
campaignSlug,
referralCode,
acceptedLegalDocuments,
);
consumeCampaignSlug();
consumeReferralCode();
tokenStorage.setTokens(response.access_token, response.refresh_token);
@@ -370,7 +389,13 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
registerWithEmail: async (email, password, firstName, referralCode) => {
registerWithEmail: async (
email,
password,
firstName,
referralCode,
acceptedLegalDocuments,
) => {
const code = referralCode || getPendingReferralCode() || undefined;
const campaignSlug = getPendingCampaignSlug() || undefined;
const response = await authApi.registerEmailStandalone({
@@ -380,6 +405,7 @@ export const useAuthStore = create<AuthState>()(
language: navigator.language.split('-')[0] || 'ru',
referral_code: code,
campaign_slug: campaignSlug,
accepted_legal_documents: acceptedLegalDocuments,
});
consumeReferralCode();
return response;

View File

@@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions {
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
// автооплатой СБП» рядом с покупкой с баланса
platega_recurrent_enabled?: boolean;
lava_recurrent_enabled?: boolean;
}
export interface ClassicPurchaseOptions {
@@ -528,6 +529,14 @@ export interface TicketDetail extends Omit<Ticket, 'messages_count' | 'last_mess
messages: TicketMessage[];
}
// Гейт согласия с офертой/политикой на экране первой авторизации.
// documents — ключи документов, которые бэк реально требует отметить.
export interface LegalConsentConfig {
required: boolean;
prechecked: boolean;
documents: string[];
}
export interface SupportConfig {
tickets_enabled: boolean;
support_type: 'tickets' | 'profile' | 'url' | 'both';
@@ -661,6 +670,18 @@ export interface SbpRecurringInfo {
redirect_url?: string | null;
}
/**
* Автопродление Lava. В отличие от Platega период задан продуктом в кабинете
* Lava и приезжает числом дней (charge_days), а не enum-интервалом.
*/
export interface LavaRecurringInfo {
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
charge_days?: number;
amount_kopeks?: number;
next_charge_at?: string | null;
redirect_url?: string | null;
}
// Ticket notifications types
export interface TicketNotification {
id: number;

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus';
describe('broadcastStatus', () => {
it('treats queued, in-progress and cancelling deliveries as running', () => {
expect(isBroadcastInFlight('queued')).toBe(true);
expect(isBroadcastInFlight('in_progress')).toBe(true);
expect(isBroadcastInFlight('cancelling')).toBe(true);
});
it('treats finished deliveries as done', () => {
expect(isBroadcastInFlight('completed')).toBe(false);
expect(isBroadcastInFlight('partial')).toBe(false);
expect(isBroadcastInFlight('failed')).toBe(false);
expect(isBroadcastInFlight('cancelled')).toBe(false);
});
it('handles a missing status without polling', () => {
expect(isBroadcastInFlight(undefined)).toBe(false);
expect(broadcastPollInterval(undefined)).toBe(false);
});
it('polls only while the delivery is running', () => {
expect(broadcastPollInterval('in_progress')).toBe(3000);
expect(broadcastPollInterval('completed')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */
export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const;
/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */
export function isBroadcastInFlight(status: string | undefined): boolean {
if (!status) return false;
return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status);
}
/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */
export function broadcastPollInterval(status: string | undefined): number | false {
return isBroadcastInFlight(status) ? 3000 : false;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { isLavaFeatureDisabledError, lavaPeriodLabelKey, lavaUiState } from './lavaRecurring';
describe('isLavaFeatureDisabledError', () => {
it('detects exactly the disabled-feature 403', () => {
expect(
isLavaFeatureDisabledError({
response: { status: 403, data: { detail: 'Lava recurrent disabled' } },
}),
).toBe(true);
});
it('ignores other 403s and malformed errors', () => {
// Другие guard'ы отдают тот же статус с иным detail — их прятать нельзя
expect(
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: 'Blacklisted' } } }),
).toBe(false);
expect(
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: { code: 'x' } } } }),
).toBe(false);
expect(isLavaFeatureDisabledError({ response: { status: 500, data: {} } })).toBe(false);
expect(isLavaFeatureDisabledError({ response: { status: 403 } })).toBe(false);
expect(isLavaFeatureDisabledError(null)).toBe(false);
expect(isLavaFeatureDisabledError('boom')).toBe(false);
});
});
describe('lavaUiState', () => {
it('hides the block when the feature is disabled', () => {
expect(lavaUiState({ status: 'ACTIVE' }, true)).toBe('hidden');
});
it('maps backend statuses to UI states', () => {
expect(lavaUiState({ status: 'PENDING' }, false)).toBe('pending');
expect(lavaUiState({ status: 'ACTIVE' }, false)).toBe('active');
expect(lavaUiState({ status: 'PAST_DUE' }, false)).toBe('past_due');
expect(lavaUiState({ status: 'none' }, false)).toBe('off');
});
it('degrades unknown or missing state to off instead of hanging', () => {
expect(lavaUiState({ status: 'SOMETHING_NEW' }, false)).toBe('off');
expect(lavaUiState(undefined, false)).toBe('off');
});
});
describe('lavaPeriodLabelKey', () => {
it('labels canonical periods', () => {
expect(lavaPeriodLabelKey(30)).toBe('subscription.lavaRecurring.period.month');
expect(lavaPeriodLabelKey(365)).toBe('subscription.lavaRecurring.period.year');
});
it('returns null for non-canonical periods so the caller falls back to "N days"', () => {
expect(lavaPeriodLabelKey(45)).toBeNull();
expect(lavaPeriodLabelKey(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,77 @@
import type { LavaRecurringInfo } from '../types';
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
/**
* true, если ошибка — это ровно "автопродление Lava выключено на бэке"
* (403 с detail==='Lava recurrent disabled'). Сравнение строгое: тот же
* 403-статус используют другие guard'ы с detail-объектом, и на любой форме
* detail (строка/объект/отсутствует) функция не должна падать.
*/
export function isLavaFeatureDisabledError(error: unknown): boolean {
if (!isRecord(error)) return false;
const response = error.response;
if (!isRecord(response)) return false;
if (response.status !== 403) return false;
const data = response.data;
if (!isRecord(data)) return false;
return data.detail === 'Lava recurrent disabled';
}
export type LavaUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
/**
* Сводит статус автопродления Lava к состоянию UI. Бэк отдаёт из GET только
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED терминальны и
* не возвращаются), но любой нераспознанный статус деградирует в 'off', а не
* подвешивает интерфейс.
*/
export function lavaUiState(
info: LavaRecurringInfo | undefined,
featureDisabled: boolean,
): LavaUiState {
if (featureDisabled) return 'hidden';
if (!info) return 'off';
switch (info.status) {
case 'PENDING':
return 'pending';
case 'ACTIVE':
return 'active';
case 'PAST_DUE':
return 'past_due';
default:
return 'off';
}
}
/**
* Локализационный ключ периодичности списания. У Lava период задан продуктом
* в кабинете провайдера и приезжает числом дней, поэтому точные подписи есть
* только для канонических значений; остальное показывается как "N дней".
*/
export function lavaPeriodLabelKey(chargeDays: number | undefined): string | null {
switch (chargeDays) {
case 1:
return 'subscription.lavaRecurring.period.day';
case 7:
return 'subscription.lavaRecurring.period.week';
case 30:
return 'subscription.lavaRecurring.period.month';
case 90:
return 'subscription.lavaRecurring.period.quarter';
case 180:
return 'subscription.lavaRecurring.period.halfYear';
case 365:
return 'subscription.lavaRecurring.period.year';
default:
return null;
}
}

26
src/utils/pricing.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getMonthlyPriceKopeks } from './pricing';
describe('getMonthlyPriceKopeks', () => {
it('hides the monthly rate for periods of a month or shorter', () => {
expect(getMonthlyPriceKopeks(4830, 7)).toBeNull();
expect(getMonthlyPriceKopeks(9900, 14)).toBeNull();
expect(getMonthlyPriceKopeks(16030, 30)).toBeNull();
});
it('divides by whole months for multiples of 30 days', () => {
expect(getMonthlyPriceKopeks(30000, 90)).toBe(10000);
expect(getMonthlyPriceKopeks(60000, 180)).toBe(10000);
});
it('prorates periods that are not whole months', () => {
expect(getMonthlyPriceKopeks(15000, 45)).toBe(10000);
expect(getMonthlyPriceKopeks(100000, 365)).toBe(8219);
});
it('returns null for non-finite input', () => {
expect(getMonthlyPriceKopeks(Number.NaN, 90)).toBeNull();
expect(getMonthlyPriceKopeks(30000, Number.NaN)).toBeNull();
expect(getMonthlyPriceKopeks(30000, Number.POSITIVE_INFINITY)).toBeNull();
});
});

15
src/utils/pricing.ts Normal file
View File

@@ -0,0 +1,15 @@
/** Длина «месяца» в днях — тот же множитель, что использует бэкенд при расчёте цены за месяц. */
const DAYS_IN_MONTH = 30;
/**
* Цена за месяц (в копейках) для периода длиной `days` дней.
*
* Возвращает `null`, когда месячная ставка не имеет смысла: для периода
* в месяц и короче она либо повторяет цену периода (30 дней), либо
* выдаёт цену периода за месячную (7 дней → цена семи дней «за месяц»).
*/
export function getMonthlyPriceKopeks(priceKopeks: number, days: number): number | null {
if (!Number.isFinite(priceKopeks) || !Number.isFinite(days)) return null;
if (days <= DAYS_IN_MONTH) return null;
return Math.round((priceKopeks * DAYS_IN_MONTH) / days);
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { parseGiftCode } from './qrScanner';
describe('parseGiftCode', () => {
it('reads the bot deep link (main distribution path)', () => {
expect(parseGiftCode('https://t.me/mybot?start=GIFT_abc123def456')).toBe('abc123def456');
});
it('reads the cabinet activation link', () => {
expect(parseGiftCode('https://cab.example.com/gift?tab=activate&code=abc123def456')).toBe(
'abc123def456',
);
});
it('reads a bare code with and without the GIFT prefix', () => {
expect(parseGiftCode('GIFT-abc123def456')).toBe('abc123def456');
expect(parseGiftCode('abc123def456')).toBe('abc123def456');
expect(parseGiftCode(' abc123def456 ')).toBe('abc123def456');
});
it('rejects unrelated QR content instead of filling the field with junk', () => {
// Иначе сканер «распознал» бы любую ссылку и подставил мусор в поле кода
expect(parseGiftCode('https://example.com/some/page')).toBeNull();
expect(parseGiftCode('WIFI:S:MyNet;T:WPA;P:secret;;')).toBeNull();
expect(parseGiftCode('short')).toBeNull();
expect(parseGiftCode('')).toBeNull();
expect(parseGiftCode(null)).toBeNull();
expect(parseGiftCode(undefined)).toBeNull();
});
});

106
src/utils/qrScanner.ts Normal file
View File

@@ -0,0 +1,106 @@
import {
isQrScannerSupported,
openQrScanner,
retrieveLaunchParams,
} from '@telegram-apps/sdk-react';
// Сканирование QR внутри Telegram: WebView не даёт нормального доступа к камере,
// поэтому на мобильных клиентах используется НАТИВНЫЙ сканер Telegram, а на вебе
// — html5-qrcode с CDN. Логика вынесена из TvQuickConnect, чтобы её мог
// переиспользовать любой экран (подарки и т.д.).
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js).
// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами
// страницы. При несовпадении браузер откажется исполнять скрипт — сканер
// деградирует в «камера недоступна», а не запускает чужой код.
const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+';
export interface Html5QrcodeInstance {
start: (
camera: { facingMode: string },
config: { fps: number; qrbox: { width: number; height: number } },
onSuccess: (decoded: string) => void,
onError: () => void,
) => Promise<void>;
stop: () => Promise<void>;
clear: () => void;
}
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
export function isTelegramMobile(): boolean {
try {
return TG_MOBILE_PLATFORMS.has(retrieveLaunchParams().tgWebAppPlatform);
} catch {
return false;
}
}
/** true — доступен нативный сканер Telegram (мобильный клиент). */
export function canUseTelegramScanner(): boolean {
try {
return isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable();
} catch {
return false;
}
}
/**
* Открывает нативный сканер Telegram. Возвращает распознанную строку либо null,
* если пользователь закрыл сканер (или он недоступен).
*/
export async function scanWithTelegram(
text: string,
capture?: (value: string) => boolean,
): Promise<string | null> {
const result = await openQrScanner({ text, capture: capture ?? (() => true) });
return result ?? null;
}
/** Подгружает html5-qrcode с CDN (в бандл не тянем — нужен редко). */
export async function loadHtml5Qrcode(): Promise<WindowWithHtml5['Html5Qrcode'] | undefined> {
const w = window as WindowWithHtml5;
if (w.Html5Qrcode) return w.Html5Qrcode;
const script = document.createElement('script');
script.src = HTML5_QRCODE_CDN;
script.integrity = HTML5_QRCODE_SRI;
script.crossOrigin = 'anonymous';
script.referrerPolicy = 'no-referrer';
document.head.appendChild(script);
await new Promise<void>((resolve) => {
script.onload = () => resolve();
// onerror срабатывает и при провале проверки целостности — вызывающий
// получит undefined и покажет «камера недоступна».
script.onerror = () => resolve();
});
return w.Html5Qrcode;
}
/**
* Достаёт код подарка из отсканированной строки.
*
* Один и тот же подарок распространяется тремя способами, и скан должен понимать
* все: deep-link бота (``?start=GIFT_<code>``), ссылка кабинета
* (``/gift?tab=activate&code=<code>``) и просто код (``GIFT-xxxx`` или голый).
*/
export function parseGiftCode(raw: string | null | undefined): string | null {
const value = (raw || '').trim();
if (!value) return null;
const startParam = value.match(/[?&]start=GIFT_([A-Za-z0-9_-]+)/i);
if (startParam) return startParam[1];
const cabinetParam = value.match(/[?&]code=([A-Za-z0-9_-]+)/i);
if (cabinetParam) return cabinetParam[1];
// Голый код: допускаем префикс GIFT- и разделители, но не произвольный текст —
// иначе сканер «распознал» бы любую ссылку и подставил мусор в поле.
const bare = value.replace(/^GIFT[-_]/i, '');
if (/^[A-Za-z0-9]{6,64}$/.test(bare)) return bare;
return null;
}