mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c44c52de | ||
|
|
0c1510fbfa | ||
|
|
602b3dd1b8 | ||
|
|
456cc2022b | ||
|
|
0f21629ba5 | ||
|
|
9a6aa1e508 | ||
|
|
b615927283 | ||
|
|
0e4dc43335 | ||
|
|
390da33914 | ||
|
|
c9afea7a50 | ||
|
|
a28f1167f7 | ||
|
|
2dce539da2 | ||
|
|
62aaa5d7c9 | ||
|
|
2f04f29b68 | ||
|
|
516399cef9 | ||
|
|
73cf2e265d | ||
|
|
2e3c4ba930 | ||
|
|
c6d8c0afe0 | ||
|
|
888a02f397 | ||
|
|
a6a7e9fb5c | ||
|
|
b37b94c144 | ||
|
|
fb1f5cf3a7 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# 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)
|
## [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
152
docs/RUNBOOK.md
Normal 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). Апстрим обычно фиксит сам, фикс прилетит следующим синком. Если
|
||||||
|
критично и срочно — можно поправить зависимость руками отдельным коммитом.
|
||||||
|
|
||||||
|
## Что стоит проверять периодически
|
||||||
|
|
||||||
|
- **Раз в 1–2 месяца — вкладка Actions.** GitHub отключает scheduled-триггер
|
||||||
|
после ~60 дней без активности в репозитории. Один ручной `Run workflow`
|
||||||
|
включает обратно.
|
||||||
|
- **Бэк и фронт бота обновлять вместе.** Синк фронта и апдейт бэкенда бота —
|
||||||
|
по-прежнему ручной процесс, специально не автоматизировали дальше деплоя.
|
||||||
|
- **CHANGELOG апстрима перед выкаткой** — на случай breaking changes в API
|
||||||
|
между бэкендом и фронтом.
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cabinet-frontend",
|
"name": "cabinet-frontend",
|
||||||
"version": "1.63.0",
|
"version": "1.64.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cabinet-frontend",
|
"name": "cabinet-frontend",
|
||||||
"version": "1.63.0",
|
"version": "1.64.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cabinet-frontend",
|
"name": "cabinet-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.63.0",
|
"version": "1.64.0",
|
||||||
"description": "Remnawave Bedolaga Web App",
|
"description": "Remnawave Bedolaga Web App",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Скачивает последнюю сборку из GitHub Release форка и раскатывает её
|
# Скачивает сборку из GitHub Release приватного репозитория и раскатывает её
|
||||||
# поверх продакшен-статики кабинета на VPS, с автоматическим бэкапом.
|
# поверх продакшен-статики кабинета на 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/"
|
# rsync -a --delete "$DEST.bak/" "$DEST/"
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -12,12 +17,28 @@ set -euo pipefail
|
|||||||
REPO="chillpadclub/bedolaga-cabinet"
|
REPO="chillpadclub/bedolaga-cabinet"
|
||||||
DEST="/home/etkr4k/remnawave-bedolaga-telegram-bot/cabinet-dist"
|
DEST="/home/etkr4k/remnawave-bedolaga-telegram-bot/cabinet-dist"
|
||||||
ASSET="cabinet-dist.tar.gz"
|
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)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
echo "Скачиваю последнюю сборку из $REPO..."
|
if [ -z "$TAG" ]; then
|
||||||
curl -fsSL "https://github.com/$REPO/releases/latest/download/$ASSET" -o "$TMP/$ASSET"
|
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 "Распаковываю..."
|
echo "Распаковываю..."
|
||||||
mkdir -p "$TMP/dist"
|
mkdir -p "$TMP/dist"
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ export const authApi = {
|
|||||||
initData: string,
|
initData: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||||
init_data: initData,
|
init_data: initData,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -40,12 +42,14 @@ export const authApi = {
|
|||||||
},
|
},
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
||||||
...data,
|
...data,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -54,12 +58,14 @@ export const authApi = {
|
|||||||
idToken: string,
|
idToken: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
||||||
id_token: idToken,
|
id_token: idToken,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -115,6 +121,7 @@ export const authApi = {
|
|||||||
language?: string;
|
language?: string;
|
||||||
referral_code?: string;
|
referral_code?: string;
|
||||||
campaign_slug?: string;
|
campaign_slug?: string;
|
||||||
|
accepted_legal_documents?: string[];
|
||||||
}): Promise<RegisterResponse> => {
|
}): Promise<RegisterResponse> => {
|
||||||
const response = await apiClient.post<RegisterResponse>(
|
const response = await apiClient.post<RegisterResponse>(
|
||||||
'/cabinet/auth/email/register/standalone',
|
'/cabinet/auth/email/register/standalone',
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export const initLogoPreload = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface BotStartVideoInfo {
|
||||||
|
has_video: boolean;
|
||||||
|
file_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const brandingApi = {
|
export const brandingApi = {
|
||||||
// Get current branding (public, no auth required)
|
// Get current branding (public, no auth required)
|
||||||
getBranding: async (): Promise<BrandingInfo> => {
|
getBranding: async (): Promise<BrandingInfo> => {
|
||||||
@@ -176,6 +181,29 @@ export const brandingApi = {
|
|||||||
return response.data;
|
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)
|
// Delete custom logo (admin only)
|
||||||
deleteLogo: async (): Promise<BrandingInfo> => {
|
deleteLogo: async (): Promise<BrandingInfo> => {
|
||||||
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
|
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface CouponBatch {
|
|||||||
period_days: number;
|
period_days: number;
|
||||||
coupons_total: number;
|
coupons_total: number;
|
||||||
wholesale_price_kopeks: number;
|
wholesale_price_kopeks: number;
|
||||||
|
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
|
||||||
|
max_per_user: number;
|
||||||
valid_until: string | null;
|
valid_until: string | null;
|
||||||
is_revoked: boolean;
|
is_revoked: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
|
|||||||
period_days: number;
|
period_days: number;
|
||||||
coupons_count: number;
|
coupons_count: number;
|
||||||
wholesale_price_kopeks?: number;
|
wholesale_price_kopeks?: number;
|
||||||
|
max_per_user?: number;
|
||||||
valid_days?: number;
|
valid_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,12 @@ export const couponsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// User: redeem a coupon for the current cabinet user
|
// 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> => {
|
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { SupportConfig } from '../types';
|
import type { LegalConsentConfig, SupportConfig } from '../types';
|
||||||
|
|
||||||
export interface FaqPage {
|
export interface FaqPage {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -125,4 +125,12 @@ export const infoApi = {
|
|||||||
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse {
|
|||||||
created_offers: number;
|
created_offers: number;
|
||||||
user_ids: number[];
|
user_ids: number[];
|
||||||
target: string | null;
|
target: string | null;
|
||||||
|
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
|
||||||
notifications_sent: number;
|
notifications_sent: number;
|
||||||
notifications_failed: 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 {
|
export interface PromoOfferTemplate {
|
||||||
@@ -206,6 +219,12 @@ export const promoOffersApi = {
|
|||||||
return response.data;
|
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
|
// Get promo offer logs
|
||||||
getLogs: async (params?: {
|
getLogs: async (params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
PurchasePreview,
|
PurchasePreview,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
SbpRecurringInfo,
|
SbpRecurringInfo,
|
||||||
|
LavaRecurringInfo,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/** Helper: build query params with optional subscription_id */
|
/** Helper: build query params with optional subscription_id */
|
||||||
@@ -394,6 +395,49 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
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 ───────────────────────────────────────────────────────────
|
// ── Trial ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface TariffListItem {
|
|||||||
show_in_gift: boolean;
|
show_in_gift: boolean;
|
||||||
is_daily: boolean;
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number;
|
daily_price_kopeks: number;
|
||||||
|
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
|
||||||
|
lava_product_id?: string | null;
|
||||||
traffic_limit_gb: number;
|
traffic_limit_gb: number;
|
||||||
device_limit: number;
|
device_limit: number;
|
||||||
tier_level: number;
|
tier_level: number;
|
||||||
@@ -85,6 +87,8 @@ export interface TariffDetail {
|
|||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily: boolean;
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number;
|
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 = глобальная настройка
|
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
|
||||||
// Внешний сквад Remnawave
|
// Внешний сквад Remnawave
|
||||||
@@ -124,6 +128,8 @@ export interface TariffCreateRequest {
|
|||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily?: boolean;
|
is_daily?: boolean;
|
||||||
daily_price_kopeks?: number;
|
daily_price_kopeks?: number;
|
||||||
|
// Автопродление Lava: продукт из кабинета Lava
|
||||||
|
lava_product_id?: string | null;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null;
|
traffic_reset_mode?: string | null;
|
||||||
// Внешний сквад Remnawave
|
// Внешний сквад Remnawave
|
||||||
@@ -168,6 +174,8 @@ export interface TariffUpdateRequest {
|
|||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily?: boolean;
|
is_daily?: boolean;
|
||||||
daily_price_kopeks?: number;
|
daily_price_kopeks?: number;
|
||||||
|
// Автопродление Lava: продукт из кабинета Lava
|
||||||
|
lava_product_id?: string | null;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null;
|
traffic_reset_mode?: string | null;
|
||||||
// Внешний сквад Remnawave
|
// Внешний сквад Remnawave
|
||||||
|
|||||||
82
src/components/LegalConsent.tsx
Normal file
82
src/components/LegalConsent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const startVideoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
@@ -25,6 +26,12 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
queryFn: brandingApi.getBranding,
|
queryFn: brandingApi.getBranding,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Видео стартового меню бота: хранится как Telegram file_id
|
||||||
|
const { data: startVideo } = useQuery({
|
||||||
|
queryKey: ['bot-start-video'],
|
||||||
|
queryFn: brandingApi.getBotStartVideo,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: fullscreenSettings } = useQuery({
|
const { data: fullscreenSettings } = useQuery({
|
||||||
queryKey: ['fullscreen-enabled'],
|
queryKey: ['fullscreen-enabled'],
|
||||||
queryFn: brandingApi.getFullscreenEnabled,
|
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 handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -211,6 +241,57 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Animated Background Editor */}
|
||||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<BackgroundEditor />
|
<BackgroundEditor />
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
buttonStylesApi,
|
buttonStylesApi,
|
||||||
ButtonStylesConfig,
|
type ButtonStylesConfig,
|
||||||
DEFAULT_BUTTON_STYLES,
|
DEFAULT_BUTTON_STYLES,
|
||||||
BUTTON_SECTIONS,
|
BUTTON_SECTIONS,
|
||||||
ButtonSection,
|
type ButtonSection,
|
||||||
BOT_LOCALES,
|
BOT_LOCALES,
|
||||||
} from '../../api/buttonStyles';
|
} from '../../api/buttonStyles';
|
||||||
import { ChevronDownIcon } from '@/components/icons';
|
import { ChevronDownIcon } from '@/components/icons';
|
||||||
@@ -18,7 +18,10 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default';
|
|||||||
|
|
||||||
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
|
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
|
||||||
{ value: 'default', colorClass: 'bg-dark-500' },
|
{ 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: 'success', colorClass: 'bg-success-500' },
|
||||||
{ value: 'danger', colorClass: 'bg-error-500' },
|
{ value: 'danger', colorClass: 'bg-error-500' },
|
||||||
];
|
];
|
||||||
@@ -228,7 +231,7 @@ export function ButtonsTab() {
|
|||||||
? 'bg-success-500 text-white'
|
? 'bg-success-500 text-white'
|
||||||
: cfg.style === 'danger'
|
: cfg.style === 'danger'
|
||||||
? 'bg-error-500 text-white'
|
? 'bg-error-500 text-white'
|
||||||
: 'bg-accent-500 text-on-accent'
|
: 'bg-[#54a9eb] text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`admin.buttons.styles.${cfg.style}`)}
|
{t(`admin.buttons.styles.${cfg.style}`)}
|
||||||
|
|||||||
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal 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;
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
retrieveLaunchParams,
|
retrieveLaunchParams,
|
||||||
} from '@telegram-apps/sdk-react';
|
} from '@telegram-apps/sdk-react';
|
||||||
import { blockButtonClass } from './blocks/buttonStyles';
|
import { blockButtonClass } from './blocks/buttonStyles';
|
||||||
|
import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner';
|
||||||
|
|
||||||
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
|
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 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 {
|
interface Props {
|
||||||
subscriptionUrl: string;
|
subscriptionUrl: string;
|
||||||
isLight: boolean;
|
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) {
|
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
@@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode
|
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode.
|
||||||
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
|
// Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой
|
||||||
const w = window as WindowWithHtml5;
|
// целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете.
|
||||||
if (!w.Html5Qrcode) {
|
const Html5Qrcode = await loadHtml5Qrcode();
|
||||||
const script = document.createElement('script');
|
if (!Html5Qrcode) {
|
||||||
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) {
|
|
||||||
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
|
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
const scanner = new w.Html5Qrcode('tv-qr-reader');
|
const scanner = new Html5Qrcode('tv-qr-reader');
|
||||||
scannerRef.current = scanner;
|
scannerRef.current = scanner;
|
||||||
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
|
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCurrency } from '../../../hooks/useCurrency';
|
|||||||
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
|
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
|
||||||
import { usePlatform } from '../../../platform';
|
import { usePlatform } from '../../../platform';
|
||||||
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
|
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
|
||||||
|
import { getMonthlyPriceKopeks } from '../../../utils/pricing';
|
||||||
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
|
||||||
import type { Tariff, TariffPeriod } from '../../../types';
|
import type { Tariff, TariffPeriod } from '../../../types';
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ export interface TariffPurchaseFormProps {
|
|||||||
balanceKopeks: number | undefined;
|
balanceKopeks: number | undefined;
|
||||||
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
|
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
|
||||||
sbpPurchaseEnabled?: boolean;
|
sbpPurchaseEnabled?: boolean;
|
||||||
|
/** Оформление привязкой Lava доступно — показать вторую CTA. */
|
||||||
|
lavaPurchaseEnabled?: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ export function TariffPurchaseForm({
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
balanceKopeks,
|
balanceKopeks,
|
||||||
sbpPurchaseEnabled = false,
|
sbpPurchaseEnabled = false,
|
||||||
|
lavaPurchaseEnabled = false,
|
||||||
onBack,
|
onBack,
|
||||||
}: TariffPurchaseFormProps) {
|
}: TariffPurchaseFormProps) {
|
||||||
const { t } = useTranslation();
|
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.
|
// Smooth scroll the form into view when first mounted.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
@@ -240,6 +285,7 @@ export function TariffPurchaseForm({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{sbpPurchaseButton}
|
{sbpPurchaseButton}
|
||||||
|
{lavaPurchaseButton}
|
||||||
|
|
||||||
{purchaseMutation.isError &&
|
{purchaseMutation.isError &&
|
||||||
!getInsufficientBalanceError(purchaseMutation.error) && (
|
!getInsufficientBalanceError(purchaseMutation.error) && (
|
||||||
@@ -279,10 +325,7 @@ export function TariffPurchaseForm({
|
|||||||
const displayDiscount = promoPeriod.percent;
|
const displayDiscount = promoPeriod.percent;
|
||||||
const displayOriginal = promoPeriod.original;
|
const displayOriginal = promoPeriod.original;
|
||||||
const displayPrice = promoPeriod.price;
|
const displayPrice = promoPeriod.price;
|
||||||
const displayPerMonth =
|
const displayPerMonth = getMonthlyPriceKopeks(displayPrice, period.days);
|
||||||
displayPrice !== period.price_kopeks
|
|
||||||
? Math.round(displayPrice / Math.max(1, period.days / 30))
|
|
||||||
: period.price_per_month_kopeks;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -317,9 +360,11 @@ export function TariffPurchaseForm({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-dark-500">
|
{displayPerMonth !== null && (
|
||||||
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
<div className="mt-1 text-xs text-dark-500">
|
||||||
</div>
|
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -666,6 +711,7 @@ export function TariffPurchaseForm({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{sbpPurchaseButton}
|
{sbpPurchaseButton}
|
||||||
|
{lavaPurchaseButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -225,6 +225,10 @@
|
|||||||
"invalidCredentials": "Invalid email or password",
|
"invalidCredentials": "Invalid email or password",
|
||||||
"tooManyAttempts": "Too many attempts. Please try again later",
|
"tooManyAttempts": "Too many attempts. Please try again later",
|
||||||
"verificationEmailNotice": "After registration, a verification email will be sent to your address",
|
"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",
|
"checkEmail": "Check your email",
|
||||||
"verificationSent": "We sent a verification link to:",
|
"verificationSent": "We sent a verification link to:",
|
||||||
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
|
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
|
||||||
@@ -701,6 +705,33 @@
|
|||||||
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
||||||
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
|
"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 product’s schedule."
|
||||||
|
},
|
||||||
"backToList": "Back to subscriptions",
|
"backToList": "Back to subscriptions",
|
||||||
"confirmDelete": "Delete subscription?",
|
"confirmDelete": "Delete subscription?",
|
||||||
"dailyAutoCharge": "Daily auto-charge",
|
"dailyAutoCharge": "Daily auto-charge",
|
||||||
@@ -1930,7 +1961,7 @@
|
|||||||
},
|
},
|
||||||
"broadcasts": {
|
"broadcasts": {
|
||||||
"title": "Broadcasts",
|
"title": "Broadcasts",
|
||||||
"subtitle": "History and management",
|
"subtitle": "Mass sending of messages to users",
|
||||||
"create": "Create Broadcast",
|
"create": "Create Broadcast",
|
||||||
"empty": "No broadcasts",
|
"empty": "No broadcasts",
|
||||||
"selectFilter": "Select audience",
|
"selectFilter": "Select audience",
|
||||||
@@ -2293,7 +2324,15 @@
|
|||||||
"offlineConv": "Offline Conversions",
|
"offlineConv": "Offline Conversions",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"legalFooter": "Legal Footer",
|
"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": {
|
"bulkActions": {
|
||||||
"title": "Bulk Actions",
|
"title": "Bulk Actions",
|
||||||
@@ -2740,7 +2779,9 @@
|
|||||||
"periodsRequired": "Add at least one period",
|
"periodsRequired": "Add at least one period",
|
||||||
"dailyPriceRequired": "Enter a price per day greater than 0",
|
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||||
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
|
"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": {
|
"servers": {
|
||||||
"title": "Squad Management",
|
"title": "Squad Management",
|
||||||
@@ -3131,7 +3172,9 @@
|
|||||||
"validDaysHint": "0 or empty — perpetual",
|
"validDaysHint": "0 or empty — perpetual",
|
||||||
"create": "Create batch",
|
"create": "Create batch",
|
||||||
"creating": "Creating…",
|
"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": {
|
"validation": {
|
||||||
"nameRequired": "Enter the batch name",
|
"nameRequired": "Enter the batch name",
|
||||||
@@ -3156,7 +3199,8 @@
|
|||||||
"validUntil": "Valid until",
|
"validUntil": "Valid until",
|
||||||
"perpetual": "Perpetual",
|
"perpetual": "Perpetual",
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"linksTitle": "Active links ({{count}})"
|
"linksTitle": "Active links ({{count}})",
|
||||||
|
"maxPerUser": "Per user"
|
||||||
},
|
},
|
||||||
"revoke": {
|
"revoke": {
|
||||||
"button": "Revoke unredeemed ({{count}})",
|
"button": "Revoke unredeemed ({{count}})",
|
||||||
@@ -3169,7 +3213,16 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Batch not found",
|
"loadFailed": "Batch not found",
|
||||||
"createFailed": "Failed to create the batch",
|
"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": {
|
"promocodes": {
|
||||||
@@ -3807,7 +3860,9 @@
|
|||||||
"sendError": "Failed to send offer",
|
"sendError": "Failed to send offer",
|
||||||
"notificationsSent": "Notifications sent",
|
"notificationsSent": "Notifications sent",
|
||||||
"offersCreated": "Offers created",
|
"offersCreated": "Offers created",
|
||||||
"notificationsFailed": "(failed: {{count}})"
|
"notificationsFailed": "(failed: {{count}})",
|
||||||
|
"deliveryTitle": "Telegram delivery",
|
||||||
|
"openAsBroadcast": "Open as broadcast"
|
||||||
},
|
},
|
||||||
"notFound": "Template not found",
|
"notFound": "Template not found",
|
||||||
"noActiveTemplates": "No active templates",
|
"noActiveTemplates": "No active templates",
|
||||||
@@ -5453,7 +5508,15 @@
|
|||||||
"shareModalActivateVia": "Activate via bot:",
|
"shareModalActivateVia": "Activate via bot:",
|
||||||
"shareModalActivateViaCabinet": "Or via website:",
|
"shareModalActivateViaCabinet": "Or via website:",
|
||||||
"copyMessage": "Copy message",
|
"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": {
|
"news": {
|
||||||
"title": "News & Updates",
|
"title": "News & Updates",
|
||||||
|
|||||||
@@ -228,6 +228,10 @@
|
|||||||
"sendResetLink": "ارسال لینک بازنشانی",
|
"sendResetLink": "ارسال لینک بازنشانی",
|
||||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||||
|
"legalConsentPrefix": "من این سند را مطالعه کردهام:",
|
||||||
|
"legalConsentTitle": "یک قدم دیگر",
|
||||||
|
"legalConsentSubtitle": "برای ساخت حساب، تأیید کنید که اسناد را مطالعه کردهاید.",
|
||||||
|
"legalConsentContinue": "ادامه",
|
||||||
"continueWithGoogle": "ادامه با Google",
|
"continueWithGoogle": "ادامه با Google",
|
||||||
"continueWithYandex": "ادامه با Yandex",
|
"continueWithYandex": "ادامه با Yandex",
|
||||||
"continueWithDiscord": "ادامه با Discord",
|
"continueWithDiscord": "ادامه با Discord",
|
||||||
@@ -276,7 +280,7 @@
|
|||||||
"traffic": "ترافیک",
|
"traffic": "ترافیک",
|
||||||
"devices": "دستگاهها",
|
"devices": "دستگاهها",
|
||||||
"expiredDate": "منقضی شده",
|
"expiredDate": "منقضی شده",
|
||||||
"activeUntil": "منقضی شده — تا {{date}}"
|
"activeUntil": "فعال تا"
|
||||||
},
|
},
|
||||||
"deviceLimitReached": "برای افزودن دستگاه جدید، دستگاههای فعلی را قطع کنید",
|
"deviceLimitReached": "برای افزودن دستگاه جدید، دستگاههای فعلی را قطع کنید",
|
||||||
"suspended": {
|
"suspended": {
|
||||||
@@ -287,9 +291,9 @@
|
|||||||
"showAll": "نمایش همه",
|
"showAll": "نمایش همه",
|
||||||
"subscriptions": "اشتراکها",
|
"subscriptions": "اشتراکها",
|
||||||
"connectDevice": "اتصال دستگاه",
|
"connectDevice": "اتصال دستگاه",
|
||||||
"devicesConnectedUnlimited": "{{count}} دستگاه متصل",
|
"devicesConnectedUnlimited": "{{used}} دستگاه متصل · نامحدود",
|
||||||
"devicesOfMax": "{{used}} از {{max}}",
|
"devicesOfMax": "{{used}} از {{max}}",
|
||||||
"maxUsage": "حداکثر",
|
"maxUsage": "حداکثر {{amount}}",
|
||||||
"remaining": "باقیمانده",
|
"remaining": "باقیمانده",
|
||||||
"stats": {
|
"stats": {
|
||||||
"balance": "موجودی",
|
"balance": "موجودی",
|
||||||
@@ -298,9 +302,9 @@
|
|||||||
"tariff": "تعرفه",
|
"tariff": "تعرفه",
|
||||||
"trafficUsageTitle": "مصرف ترافیک",
|
"trafficUsageTitle": "مصرف ترافیک",
|
||||||
"trialOffer": {
|
"trialOffer": {
|
||||||
"freeDesc": "{{days}} روز رایگان آزمایش کنید — بدون پرداخت",
|
"freeDesc": "ویپیان ما را رایگان امتحان کنید: بدون هیچ تعهدی",
|
||||||
"freeTitle": "دوره آزمایشی رایگان",
|
"freeTitle": "دوره آزمایشی رایگان",
|
||||||
"paidDesc": "{{days}} روز آزمایشی فقط با {{price}}",
|
"paidDesc": "با یک اشتراک آزمایشی شروع کنید",
|
||||||
"paidTitle": "دوره آزمایشی پرداختی"
|
"paidTitle": "دوره آزمایشی پرداختی"
|
||||||
},
|
},
|
||||||
"unlimited": "نامحدود",
|
"unlimited": "نامحدود",
|
||||||
@@ -458,7 +462,7 @@
|
|||||||
"newDeviceLimit": "محدودیت جدید دستگاه",
|
"newDeviceLimit": "محدودیت جدید دستگاه",
|
||||||
"reduce": "کاهش",
|
"reduce": "کاهش",
|
||||||
"reduceDevices": "کاهش تعداد دستگاهها",
|
"reduceDevices": "کاهش تعداد دستگاهها",
|
||||||
"reduceDevicesDescription": "محدودیت دستگاههای خود را تنظیم کنید. در صورت لزوم ابتدا دستگاههای اضافه را قطع کنید",
|
"reduceDevicesDescription": "میتوانید تعداد دستگاهها را تا حداقل مجاز در تعرفه کاهش دهید",
|
||||||
"reduceDevicesTitle": "کاهش تعداد دستگاهها",
|
"reduceDevicesTitle": "کاهش تعداد دستگاهها",
|
||||||
"reduceUnavailable": "در حال حاضر کاهش تعداد دستگاهها امکانپذیر نیست",
|
"reduceUnavailable": "در حال حاضر کاهش تعداد دستگاهها امکانپذیر نیست",
|
||||||
"reducing": "در حال کاهش…"
|
"reducing": "در حال کاهش…"
|
||||||
@@ -600,13 +604,40 @@
|
|||||||
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
||||||
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید میکند؛ تمدیدها بهصورت خودکار برداشت میشوند."
|
"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": "بازگشت به فهرست اشتراکها",
|
"backToList": "بازگشت به فهرست اشتراکها",
|
||||||
"confirmDelete": "اشتراک حذف شود؟",
|
"confirmDelete": "اشتراک حذف شود؟",
|
||||||
"cta": {
|
"cta": {
|
||||||
"renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
|
"renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
|
||||||
"activeHint": "اشتراک فعال است — میتوانید دستگاهها را متصل کرده و از VPN استفاده کنید",
|
"activeHint": "تمدید و تغییر تعرفه",
|
||||||
"expiredHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
|
"expiredHint": "یک تعرفه انتخاب و پرداخت کنید",
|
||||||
"trialHint": "شما در دوره آزمایشی هستید — برای دسترسی به همه امکانات به تعرفه پرداختی ارتقا دهید"
|
"trialHint": "ترافیک و دستگاه بیشتر"
|
||||||
},
|
},
|
||||||
"dailyAutoCharge": "برداشت خودکار روزانه",
|
"dailyAutoCharge": "برداشت خودکار روزانه",
|
||||||
"defaultName": "اشتراک",
|
"defaultName": "اشتراک",
|
||||||
@@ -626,16 +657,16 @@
|
|||||||
"statusTrial": "آزمایشی",
|
"statusTrial": "آزمایشی",
|
||||||
"daysShort": "روز",
|
"daysShort": "روز",
|
||||||
"expiredBanner": {
|
"expiredBanner": {
|
||||||
"selectTariff": "تعرفه را انتخاب کنید",
|
"selectTariff": "اشتراک شما منقضی شده است. برای بازیابی دسترسی به VPN، یک تعرفه را از پایین انتخاب کنید.",
|
||||||
"title": "اشتراک منقضی شده است"
|
"title": "اشتراک منقضی شده است"
|
||||||
},
|
},
|
||||||
"trialInfo": {
|
"trialInfo": {
|
||||||
"description": "{{days}} روز سرویس ما را رایگان امتحان کنید",
|
"description": "شما در حال استفاده از نسخهٔ آزمایشی سرویس هستید. برای دسترسی کامل، یک تعرفه را در پایین انتخاب کنید.",
|
||||||
"remaining": "{{count}} روز باقیمانده",
|
"remaining": "باقیمانده",
|
||||||
"title": "دوره آزمایشی"
|
"title": "دوره آزمایشی"
|
||||||
},
|
},
|
||||||
"trialUpgrade": {
|
"trialUpgrade": {
|
||||||
"description": "برای دسترسی به همه امکانات و ادامه استفاده به اشتراک پرداختی ارتقا دهید",
|
"description": "دوره آزمایشی شما بهزودی به پایان میرسد. برای ادامه استفاده از VPN بدون محدودیت، یک تعرفه مناسب انتخاب کنید.",
|
||||||
"title": "ارتقای اشتراک"
|
"title": "ارتقای اشتراک"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1008,7 +1039,7 @@
|
|||||||
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.",
|
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.",
|
||||||
"selectSubscription": "ابتدا یک اشتراک انتخاب کنید"
|
"selectSubscription": "ابتدا یک اشتراک انتخاب کنید"
|
||||||
},
|
},
|
||||||
"payWithStars": "پرداخت {stars} Stars",
|
"payWithStars": "پرداخت Stars",
|
||||||
"processingPayment": "در حال پردازش...",
|
"processingPayment": "در حال پردازش...",
|
||||||
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
|
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
|
||||||
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
|
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
|
||||||
@@ -1564,7 +1595,7 @@
|
|||||||
},
|
},
|
||||||
"broadcasts": {
|
"broadcasts": {
|
||||||
"title": "نشریات",
|
"title": "نشریات",
|
||||||
"subtitle": "تاریخچه و مدیریت",
|
"subtitle": "ارسال گروهی پیام به کاربران",
|
||||||
"create": "ایجاد پیام",
|
"create": "ایجاد پیام",
|
||||||
"empty": "هیچ نشریهای نیست",
|
"empty": "هیچ نشریهای نیست",
|
||||||
"selectFilter": "انتخاب مخاطبین",
|
"selectFilter": "انتخاب مخاطبین",
|
||||||
@@ -1652,46 +1683,46 @@
|
|||||||
"telegramSection": "ارسال انبوه تلگرام"
|
"telegramSection": "ارسال انبوه تلگرام"
|
||||||
},
|
},
|
||||||
"pinnedMessages": {
|
"pinnedMessages": {
|
||||||
"title": "Pinned Messages",
|
"title": "پیامهای سنجاقشده",
|
||||||
"subtitle": "Manage pinned messages for users",
|
"subtitle": "مدیریت پیامهای سنجاقشده برای کاربران",
|
||||||
"create": "Create",
|
"create": "ایجاد",
|
||||||
"empty": "No pinned messages",
|
"empty": "هیچ پیام سنجاقشدهای وجود ندارد",
|
||||||
"content": "Message text",
|
"content": "متن پیام",
|
||||||
"contentPlaceholder": "Enter pinned message text...",
|
"contentPlaceholder": "متن پیام سنجاقشده را وارد کنید...",
|
||||||
"noContent": "No text",
|
"noContent": "بدون متن",
|
||||||
"media": "Media file",
|
"media": "فایل رسانه",
|
||||||
"addMedia": "Add media",
|
"addMedia": "افزودن رسانه",
|
||||||
"uploading": "Uploading...",
|
"uploading": "در حال بارگذاری...",
|
||||||
"settings": "Settings",
|
"settings": "تنظیمات",
|
||||||
"sendBeforeMenu": "Send before menu",
|
"sendBeforeMenu": "ارسال قبل از منو",
|
||||||
"sendOnEveryStart": "Send on every start",
|
"sendOnEveryStart": "ارسال در هر بار شروع",
|
||||||
"broadcast": "Broadcast",
|
"broadcast": "ارسال همگانی",
|
||||||
"broadcastOnCreate": "Broadcast on create",
|
"broadcastOnCreate": "ارسال همگانی هنگام ایجاد",
|
||||||
"broadcastConfirm": "Broadcast pinned message to all active users?",
|
"broadcastConfirm": "پیام سنجاقشده برای همه کاربران فعال ارسال شود؟",
|
||||||
"broadcastToAll": "Broadcast to all",
|
"broadcastToAll": "ارسال به همه",
|
||||||
"activate": "Activate",
|
"activate": "فعالسازی",
|
||||||
"deactivate": "Deactivate",
|
"deactivate": "غیرفعالسازی",
|
||||||
"unpin": "Unpin",
|
"unpin": "برداشتن سنجاق",
|
||||||
"unpinAll": "Unpin for everyone",
|
"unpinAll": "برداشتن سنجاق برای همه",
|
||||||
"unpinConfirm": "Unpin message for all users?",
|
"unpinConfirm": "سنجاق پیام برای همه کاربران برداشته شود؟",
|
||||||
"delete": "Delete",
|
"delete": "حذف",
|
||||||
"deleteConfirm": "Delete this pinned message?",
|
"deleteConfirm": "این پیام سنجاقشده حذف شود؟",
|
||||||
"active": "Active",
|
"active": "فعال",
|
||||||
"inactive": "Inactive",
|
"inactive": "غیرفعال",
|
||||||
"prev": "Previous",
|
"prev": "قبلی",
|
||||||
"next": "Next",
|
"next": "بعدی",
|
||||||
"createSuccess": "Pinned message created",
|
"createSuccess": "پیام سنجاقشده ایجاد شد",
|
||||||
"updateSuccess": "Message updated",
|
"updateSuccess": "پیام بهروزرسانی شد",
|
||||||
"deleteSuccess": "Message deleted",
|
"deleteSuccess": "پیام حذف شد",
|
||||||
"activateSuccess": "Message activated",
|
"activateSuccess": "پیام فعال شد",
|
||||||
"deactivateSuccess": "Message deactivated",
|
"deactivateSuccess": "پیام غیرفعال شد",
|
||||||
"broadcastSuccess": "Broadcast completed",
|
"broadcastSuccess": "ارسال همگانی تکمیل شد",
|
||||||
"unpinSuccess": "Messages unpinned",
|
"unpinSuccess": "سنجاق پیامها برداشته شد",
|
||||||
"sentCount": "Sent",
|
"sentCount": "ارسالشده",
|
||||||
"failedCount": "Failed",
|
"failedCount": "ناموفق",
|
||||||
"unpinnedCount": "Unpinned",
|
"unpinnedCount": "سنجاقبرداشته",
|
||||||
"editMessage": "Edit",
|
"editMessage": "ویرایش",
|
||||||
"cantDeleteActive": "Cannot delete active message"
|
"cantDeleteActive": "نمیتوان پیام فعال را حذف کرد"
|
||||||
},
|
},
|
||||||
"channelSubscriptions": {
|
"channelSubscriptions": {
|
||||||
"title": "کانالهای اجباری",
|
"title": "کانالهای اجباری",
|
||||||
@@ -1956,7 +1987,15 @@
|
|||||||
"offlineConv": "تبدیلهای آفلاین",
|
"offlineConv": "تبدیلهای آفلاین",
|
||||||
"apiKey": "کلید API",
|
"apiKey": "کلید API",
|
||||||
"legalFooter": "پاورقی حقوقی",
|
"legalFooter": "پاورقی حقوقی",
|
||||||
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداختهای مکرر در پایین صفحه ورود"
|
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداختهای مکرر در پایین صفحه ورود",
|
||||||
|
"botStartVideo": "ویدیوی منوی شروع ربات",
|
||||||
|
"botStartVideoDesc": "ربات این ویدیو را بهجای تصویر لوگو به پیام /start پیوست میکند. فایل یکبار در تلگرام بارگذاری میشود و ما فقط شناسه آن را نگه میداریم.",
|
||||||
|
"botStartVideoUpload": "بارگذاری ویدیو",
|
||||||
|
"botStartVideoReplace": "جایگزینی ویدیو",
|
||||||
|
"botStartVideoRemove": "حذف",
|
||||||
|
"botStartVideoActive": "ویدیو تنظیم شده است",
|
||||||
|
"botStartVideoNone": "ویدیویی تنظیم نشده — منو از تصویر لوگو استفاده میکند",
|
||||||
|
"botStartVideoError": "بارگذاری ویدیو ناموفق بود"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"color": "رنگ دکمه",
|
"color": "رنگ دکمه",
|
||||||
@@ -2005,7 +2044,7 @@
|
|||||||
"builtinButtons": "بخشهای داخلی",
|
"builtinButtons": "بخشهای داخلی",
|
||||||
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
|
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
|
||||||
"buttonTextPlaceholder": "متن دکمه",
|
"buttonTextPlaceholder": "متن دکمه",
|
||||||
"customLabelsHint": "متن دکمه برای هر زبان ربات",
|
"customLabelsHint": "خالی = عنوان پیشفرض",
|
||||||
"moveUp": "انتقال به بالا",
|
"moveUp": "انتقال به بالا",
|
||||||
"moveDown": "انتقال به پایین",
|
"moveDown": "انتقال به پایین",
|
||||||
"openIn": "باز کردن در",
|
"openIn": "باز کردن در",
|
||||||
@@ -2278,7 +2317,9 @@
|
|||||||
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
||||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||||
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
||||||
}
|
},
|
||||||
|
"lavaProductLabel": "محصول Lava (تمدید خودکار)",
|
||||||
|
"lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم میشود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست."
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "مدیریت اسکوادها",
|
"title": "مدیریت اسکوادها",
|
||||||
@@ -2625,10 +2666,10 @@
|
|||||||
"programEnabled": "برنامه معرفی فعال است",
|
"programEnabled": "برنامه معرفی فعال است",
|
||||||
"programEnabledDesc": "تمام عملکرد ارجاع را فعال میکند",
|
"programEnabledDesc": "تمام عملکرد ارجاع را فعال میکند",
|
||||||
"requisitesText": "متن اطلاعات حساب",
|
"requisitesText": "متن اطلاعات حساب",
|
||||||
"requisitesTextDesc": "در فرم درخواست همکاری به کاربر نمایش داده میشود",
|
"requisitesTextDesc": "متن سفارشی برای توضیح به کاربران که هنگام برداشت چه اطلاعات پرداختی را وارد کنند",
|
||||||
"requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...",
|
"requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...",
|
||||||
"withdrawalEnabled": "برداشت مجاز است",
|
"withdrawalEnabled": "برداشت مجاز است",
|
||||||
"withdrawalEnabledDesc": "به کاربران اجازه میدهد درخواست برداشت بدهند"
|
"withdrawalEnabledDesc": "اجازه دادن به همکاران برای درخواست برداشت موجودی معرفی"
|
||||||
},
|
},
|
||||||
"settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود",
|
"settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود",
|
||||||
"settingsSection": {
|
"settingsSection": {
|
||||||
@@ -3305,7 +3346,9 @@
|
|||||||
"notificationsFailed": "(ناموفق: {{count}})",
|
"notificationsFailed": "(ناموفق: {{count}})",
|
||||||
"sendError": "ارسال پیشنهاد ناموفق بود",
|
"sendError": "ارسال پیشنهاد ناموفق بود",
|
||||||
"notificationsSent": "اعلانهای ارسال شده",
|
"notificationsSent": "اعلانهای ارسال شده",
|
||||||
"offersCreated": "پیشنهادهای ایجاد شده"
|
"offersCreated": "پیشنهادهای ایجاد شده",
|
||||||
|
"deliveryTitle": "تحویل در تلگرام",
|
||||||
|
"openAsBroadcast": "نمایش بهعنوان ارسال گروهی"
|
||||||
},
|
},
|
||||||
"notFound": "قالب یافت نشد",
|
"notFound": "قالب یافت نشد",
|
||||||
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
||||||
@@ -3398,7 +3441,7 @@
|
|||||||
"usersOnlineCount": "{{count}} آنلاین"
|
"usersOnlineCount": "{{count}} آنلاین"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "پهنای باند لحظهای",
|
"bandwidth": "ترافیک ورودیها",
|
||||||
"connections": "اتصالات",
|
"connections": "اتصالات",
|
||||||
"cpu": "هستههای CPU",
|
"cpu": "هستههای CPU",
|
||||||
"download": "دانلود",
|
"download": "دانلود",
|
||||||
@@ -4940,40 +4983,48 @@
|
|||||||
},
|
},
|
||||||
"activateButton": "فعالسازی",
|
"activateButton": "فعالسازی",
|
||||||
"activateCodePlaceholder": "کد هدیه را وارد کنید",
|
"activateCodePlaceholder": "کد هدیه را وارد کنید",
|
||||||
"activatedBy": "توسط {{name}} فعال شد",
|
"activatedBy": "توسط {{username}} فعال شد",
|
||||||
"activateDescription": "برای فعالسازی اشتراک، کد هدیه دریافتی را وارد کنید",
|
"activateDescription": "برای فعالسازی اشتراک، کد هدیه دریافتی را وارد کنید",
|
||||||
"activatedGiftsTitle": "هدایای فعالشده",
|
"activatedGiftsTitle": "هدایای فعالشده",
|
||||||
"activateError": "فعالسازی هدیه ناموفق بود",
|
"activateError": "فعالسازی هدیه ناموفق بود",
|
||||||
"activateSelfError": "نمیتوانید هدیه خودتان را فعال کنید",
|
"activateSelfError": "نمیتوانید هدیه خودتان را فعال کنید",
|
||||||
"activateSuccess": "هدیه فعال شد!",
|
"activateSuccess": "هدیه فعال شد!",
|
||||||
"activateSuccessDesc": "اشتراک به حساب شما افزوده شد",
|
"activateSuccessDesc": "{{tariff}}: {{days}} روز به اشتراک شما اضافه شد",
|
||||||
"activateTitle": "فعالسازی هدیه",
|
"activateTitle": "فعالسازی هدیه",
|
||||||
"activeGiftsTitle": "هدایای فعال",
|
"activeGiftsTitle": "هدایای فعال",
|
||||||
"codeLabel": "کد هدیه",
|
"codeLabel": "کد هدیه",
|
||||||
"codeReadyTitle": "کد هدیه شما آماده است",
|
"codeReadyTitle": "کد هدیه شما آماده است",
|
||||||
"copyMessage": "کپی پیام",
|
"copyMessage": "کپی پیام",
|
||||||
"daysShort": "روز",
|
"daysShort": "روز",
|
||||||
"deviceCount": "تعداد دستگاهها",
|
"deviceCount": "{{count}} دستگاه",
|
||||||
"devicesShort": "دستگاه",
|
"devicesShort": "دستگاه",
|
||||||
"gbShort": "گیگابایت",
|
"gbShort": "گیگابایت",
|
||||||
"myGiftsEmpty": "هنوز هدیهای ندارید",
|
"myGiftsEmpty": "هنوز هدیهای ندارید",
|
||||||
"myGiftsEmptyDesc": "هدایایی که ارسال یا دریافت میکنید اینجا نمایش داده میشوند",
|
"myGiftsEmptyDesc": "برای یک دوست هدیه بخرید یا کد دریافتی را فعال کنید",
|
||||||
"pageTitle": "هدایا",
|
"pageTitle": "هدایا",
|
||||||
"receivedGiftsTitle": "هدایای دریافتشده",
|
"receivedGiftsTitle": "هدایای دریافتشده",
|
||||||
"selectPeriod": "دوره را انتخاب کنید",
|
"selectPeriod": "دوره را انتخاب کنید",
|
||||||
"selectTariff": "تعرفه را انتخاب کنید",
|
"selectTariff": "تعرفه را انتخاب کنید",
|
||||||
"sentTo": "به {{name}} ارسال شد",
|
"sentTo": "به {{recipient}} ارسال شد",
|
||||||
"shareGift": "به اشتراک گذاشتن هدیه",
|
"shareGift": "به اشتراک گذاشتن هدیه",
|
||||||
"shareModalActivateVia": "از طریق فعالسازی",
|
"shareModalActivateVia": "فعالسازی از طریق ربات:",
|
||||||
"shareModalActivateViaCabinet": "از طریق کابین",
|
"shareModalActivateViaCabinet": "از طریق کابین",
|
||||||
"shareText": "برای شما هدیهای از Bedolaga VPN دارم: {{code}}",
|
"shareText": "برای تو یک هدیهٔ Bedolaga VPN دارم، از همینجا فعالش کن:",
|
||||||
"shareToastCopied": "لینک کپی شد",
|
"shareToastCopied": "لینک کپی شد",
|
||||||
"statusActivated": "فعال شده",
|
"statusActivated": "فعال شده",
|
||||||
"statusAvailable": "قابل فعالسازی",
|
"statusAvailable": "قابل فعالسازی",
|
||||||
"tabActivate": "فعالسازی",
|
"tabActivate": "فعالسازی",
|
||||||
"tabBuy": "خرید",
|
"tabBuy": "خرید",
|
||||||
"tabMyGifts": "هدایای من",
|
"tabMyGifts": "هدایای من",
|
||||||
"unlimitedTraffic": "ترافیک نامحدود"
|
"unlimitedTraffic": "ترافیک نامحدود",
|
||||||
|
"qrHint": "برای فعالسازی هدیه اسکن کنید",
|
||||||
|
"scanButton": "📷 اسکن QR",
|
||||||
|
"scanInProgress": "در حال اسکن…",
|
||||||
|
"scanCancel": "توقف اسکن",
|
||||||
|
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
|
||||||
|
"scanNotRecognized": "در این QR کد هدیهای نیست",
|
||||||
|
"scanError": "اسکن ناموفق بود",
|
||||||
|
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "اخبار و بهروزرسانیها",
|
"title": "اخبار و بهروزرسانیها",
|
||||||
|
|||||||
@@ -231,6 +231,10 @@
|
|||||||
"sendResetLink": "Отправить ссылку",
|
"sendResetLink": "Отправить ссылку",
|
||||||
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
|
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
|
||||||
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
|
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
|
||||||
|
"legalConsentPrefix": "Я ознакомлен(а) с документом",
|
||||||
|
"legalConsentTitle": "Ещё один шаг",
|
||||||
|
"legalConsentSubtitle": "Чтобы создать аккаунт, подтвердите, что ознакомились с документами.",
|
||||||
|
"legalConsentContinue": "Продолжить",
|
||||||
"checkEmail": "Проверьте почту",
|
"checkEmail": "Проверьте почту",
|
||||||
"verificationSent": "Мы отправили ссылку для подтверждения на:",
|
"verificationSent": "Мы отправили ссылку для подтверждения на:",
|
||||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||||
@@ -720,6 +724,33 @@
|
|||||||
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
||||||
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
|
"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": "К списку подписок",
|
"backToList": "К списку подписок",
|
||||||
"confirmDelete": "Удалить подписку?",
|
"confirmDelete": "Удалить подписку?",
|
||||||
"dailyAutoCharge": "Ежедневное списание",
|
"dailyAutoCharge": "Ежедневное списание",
|
||||||
@@ -2796,7 +2827,15 @@
|
|||||||
"offlineConv": "Офлайн-конверсии",
|
"offlineConv": "Офлайн-конверсии",
|
||||||
"apiKey": "API-ключ",
|
"apiKey": "API-ключ",
|
||||||
"legalFooter": "Юридический футер",
|
"legalFooter": "Юридический футер",
|
||||||
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа"
|
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа",
|
||||||
|
"botStartVideo": "Видео стартового меню бота",
|
||||||
|
"botStartVideoDesc": "Бот прикрепит это видео к сообщению /start вместо картинки-логотипа. Файл разово отправляется в Telegram — храним только его идентификатор.",
|
||||||
|
"botStartVideoUpload": "Загрузить видео",
|
||||||
|
"botStartVideoReplace": "Заменить видео",
|
||||||
|
"botStartVideoRemove": "Удалить",
|
||||||
|
"botStartVideoActive": "Видео подключено",
|
||||||
|
"botStartVideoNone": "Видео не задано — меню с картинкой-логотипом",
|
||||||
|
"botStartVideoError": "Не удалось загрузить видео"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"color": "Цвет кнопки",
|
"color": "Цвет кнопки",
|
||||||
@@ -3129,7 +3168,9 @@
|
|||||||
"periodsRequired": "Добавьте хотя бы один период",
|
"periodsRequired": "Добавьте хотя бы один период",
|
||||||
"dailyPriceRequired": "Укажите цену за день больше 0",
|
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||||
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
||||||
}
|
},
|
||||||
|
"lavaProductLabel": "Продукт Lava (автопродление)",
|
||||||
|
"lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно."
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Управление сквадами",
|
"title": "Управление сквадами",
|
||||||
@@ -3523,7 +3564,9 @@
|
|||||||
"validDaysHint": "0 или пусто — бессрочно",
|
"validDaysHint": "0 или пусто — бессрочно",
|
||||||
"create": "Создать партию",
|
"create": "Создать партию",
|
||||||
"creating": "Создание…",
|
"creating": "Создание…",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена",
|
||||||
|
"maxPerUser": "Лимит на пользователя",
|
||||||
|
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Укажите название партии",
|
"nameRequired": "Укажите название партии",
|
||||||
@@ -3548,7 +3591,8 @@
|
|||||||
"validUntil": "Действует до",
|
"validUntil": "Действует до",
|
||||||
"perpetual": "Бессрочно",
|
"perpetual": "Бессрочно",
|
||||||
"createdAt": "Создана",
|
"createdAt": "Создана",
|
||||||
"linksTitle": "Активные ссылки ({{count}} шт.)"
|
"linksTitle": "Активные ссылки ({{count}} шт.)",
|
||||||
|
"maxPerUser": "На пользователя"
|
||||||
},
|
},
|
||||||
"revoke": {
|
"revoke": {
|
||||||
"button": "Отозвать непогашенные ({{count}})",
|
"button": "Отозвать непогашенные ({{count}})",
|
||||||
@@ -3561,7 +3605,16 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Партия не найдена",
|
"loadFailed": "Партия не найдена",
|
||||||
"createFailed": "Не удалось создать партию",
|
"createFailed": "Не удалось создать партию",
|
||||||
"revokeFailed": "Не удалось отозвать партию"
|
"revokeFailed": "Не удалось отозвать партию",
|
||||||
|
"deleteFailed": "Не удалось удалить партию"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"button": "🗑 Удалить партию",
|
||||||
|
"confirmTitle": "Удалить партию?",
|
||||||
|
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
|
||||||
|
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
|
||||||
|
"confirm": "Удалить",
|
||||||
|
"success": "Партия удалена (купонов: {{count}})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
@@ -4208,7 +4261,9 @@
|
|||||||
"sendError": "Не удалось отправить предложение",
|
"sendError": "Не удалось отправить предложение",
|
||||||
"notificationsSent": "Отправлено уведомлений",
|
"notificationsSent": "Отправлено уведомлений",
|
||||||
"offersCreated": "Создано офферов",
|
"offersCreated": "Создано офферов",
|
||||||
"notificationsFailed": "(не отправлено: {{count}})"
|
"notificationsFailed": "(не отправлено: {{count}})",
|
||||||
|
"deliveryTitle": "Доставка в Telegram",
|
||||||
|
"openAsBroadcast": "Открыть как рассылку"
|
||||||
},
|
},
|
||||||
"notFound": "Шаблон не найден",
|
"notFound": "Шаблон не найден",
|
||||||
"noActiveTemplates": "Нет активных шаблонов",
|
"noActiveTemplates": "Нет активных шаблонов",
|
||||||
@@ -6007,7 +6062,15 @@
|
|||||||
"shareModalActivateVia": "Активировать через бота:",
|
"shareModalActivateVia": "Активировать через бота:",
|
||||||
"shareModalActivateViaCabinet": "Или через сайт:",
|
"shareModalActivateViaCabinet": "Или через сайт:",
|
||||||
"copyMessage": "Скопировать сообщение",
|
"copyMessage": "Скопировать сообщение",
|
||||||
"shareToastCopied": "Сообщение скопировано"
|
"shareToastCopied": "Сообщение скопировано",
|
||||||
|
"qrHint": "Отсканируйте, чтобы активировать подарок",
|
||||||
|
"scanButton": "📷 Отсканировать QR",
|
||||||
|
"scanInProgress": "Сканирование…",
|
||||||
|
"scanCancel": "Остановить сканирование",
|
||||||
|
"scanDescription": "Наведите камеру на QR-код подарка",
|
||||||
|
"scanNotRecognized": "В QR-коде нет кода подарка",
|
||||||
|
"scanError": "Не удалось отсканировать",
|
||||||
|
"scanNoCamera": "Камера недоступна — введите код вручную"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "Новости и обновления",
|
"title": "Новости и обновления",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"currency": "¥",
|
"currency": "¥",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"all": "All",
|
"all": "全部",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
@@ -228,6 +228,10 @@
|
|||||||
"sendResetLink": "发送重置链接",
|
"sendResetLink": "发送重置链接",
|
||||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||||
|
"legalConsentPrefix": "我已阅读",
|
||||||
|
"legalConsentTitle": "还差一步",
|
||||||
|
"legalConsentSubtitle": "创建账户前,请确认您已阅读以下文件。",
|
||||||
|
"legalConsentContinue": "继续",
|
||||||
"continueWithGoogle": "通过 Google 继续",
|
"continueWithGoogle": "通过 Google 继续",
|
||||||
"continueWithYandex": "通过 Yandex 继续",
|
"continueWithYandex": "通过 Yandex 继续",
|
||||||
"continueWithDiscord": "通过 Discord 继续",
|
"continueWithDiscord": "通过 Discord 继续",
|
||||||
@@ -276,7 +280,7 @@
|
|||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
"devices": "设备",
|
"devices": "设备",
|
||||||
"expiredDate": "过期时间",
|
"expiredDate": "过期时间",
|
||||||
"activeUntil": "已过期 — 至 {{date}}"
|
"activeUntil": "有效期至"
|
||||||
},
|
},
|
||||||
"deviceLimitReached": "断开设备以添加新设备",
|
"deviceLimitReached": "断开设备以添加新设备",
|
||||||
"suspended": {
|
"suspended": {
|
||||||
@@ -287,9 +291,9 @@
|
|||||||
"showAll": "全部显示",
|
"showAll": "全部显示",
|
||||||
"subscriptions": "订阅",
|
"subscriptions": "订阅",
|
||||||
"connectDevice": "连接设备",
|
"connectDevice": "连接设备",
|
||||||
"devicesConnectedUnlimited": "已连接 {{count}} 台设备",
|
"devicesConnectedUnlimited": "已连接 {{used}} 台设备 · 无限制",
|
||||||
"devicesOfMax": "{{used}} / {{max}}",
|
"devicesOfMax": "{{used}} / {{max}}",
|
||||||
"maxUsage": "最大",
|
"maxUsage": "最大 {{amount}}",
|
||||||
"remaining": "剩余",
|
"remaining": "剩余",
|
||||||
"stats": {
|
"stats": {
|
||||||
"balance": "余额",
|
"balance": "余额",
|
||||||
@@ -298,9 +302,9 @@
|
|||||||
"tariff": "套餐",
|
"tariff": "套餐",
|
||||||
"trafficUsageTitle": "流量使用",
|
"trafficUsageTitle": "流量使用",
|
||||||
"trialOffer": {
|
"trialOffer": {
|
||||||
"freeDesc": "免费试用 {{days}} 天 — 无需付款",
|
"freeDesc": "免费试用我们的 VPN:无需任何承诺",
|
||||||
"freeTitle": "免费试用",
|
"freeTitle": "免费试用",
|
||||||
"paidDesc": "{{days}} 天试用,仅需 {{price}}",
|
"paidDesc": "从试用订阅开始吧",
|
||||||
"paidTitle": "付费试用"
|
"paidTitle": "付费试用"
|
||||||
},
|
},
|
||||||
"unlimited": "无限",
|
"unlimited": "无限",
|
||||||
@@ -458,7 +462,7 @@
|
|||||||
"newDeviceLimit": "新设备限制",
|
"newDeviceLimit": "新设备限制",
|
||||||
"reduce": "减少",
|
"reduce": "减少",
|
||||||
"reduceDevices": "减少设备数量",
|
"reduceDevices": "减少设备数量",
|
||||||
"reduceDevicesDescription": "调整您的设备限制。如有需要,请先断开多余设备的连接",
|
"reduceDevicesDescription": "您可以将设备数量减少至套餐的最低限制",
|
||||||
"reduceDevicesTitle": "减少设备数量",
|
"reduceDevicesTitle": "减少设备数量",
|
||||||
"reduceUnavailable": "当前无法减少设备数量",
|
"reduceUnavailable": "当前无法减少设备数量",
|
||||||
"reducing": "正在减少…"
|
"reducing": "正在减少…"
|
||||||
@@ -600,13 +604,40 @@
|
|||||||
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
||||||
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
|
"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": "返回订阅列表",
|
"backToList": "返回订阅列表",
|
||||||
"confirmDelete": "删除订阅?",
|
"confirmDelete": "删除订阅?",
|
||||||
"cta": {
|
"cta": {
|
||||||
"renewHint": "订阅已过期 — 续订以继续使用",
|
"renewHint": "订阅已过期 — 续订以继续使用",
|
||||||
"activeHint": "订阅有效 — 您可以连接设备并使用 VPN",
|
"activeHint": "续费和更换套餐",
|
||||||
"expiredHint": "订阅已过期 — 续订以继续使用",
|
"expiredHint": "选择套餐并付款",
|
||||||
"trialHint": "您正在试用中 — 升级到付费套餐以解锁所有功能"
|
"trialHint": "更多流量和设备"
|
||||||
},
|
},
|
||||||
"dailyAutoCharge": "每日自动扣费",
|
"dailyAutoCharge": "每日自动扣费",
|
||||||
"defaultName": "订阅",
|
"defaultName": "订阅",
|
||||||
@@ -626,16 +657,16 @@
|
|||||||
"statusTrial": "试用",
|
"statusTrial": "试用",
|
||||||
"daysShort": "天",
|
"daysShort": "天",
|
||||||
"expiredBanner": {
|
"expiredBanner": {
|
||||||
"selectTariff": "选择套餐",
|
"selectTariff": "您的订阅已到期。请在下方选择套餐,以恢复 VPN 访问。",
|
||||||
"title": "订阅已过期"
|
"title": "订阅已过期"
|
||||||
},
|
},
|
||||||
"trialInfo": {
|
"trialInfo": {
|
||||||
"description": "免费试用我们的服务 {{days}} 天",
|
"description": "您正在使用服务的试用版。请在下方选择套餐以获得完整访问权限。",
|
||||||
"remaining": "剩余 {{count}} 天",
|
"remaining": "剩余",
|
||||||
"title": "试用期"
|
"title": "试用期"
|
||||||
},
|
},
|
||||||
"trialUpgrade": {
|
"trialUpgrade": {
|
||||||
"description": "升级到付费订阅以解锁所有功能并继续使用",
|
"description": "您的试用期即将结束。请选择合适的套餐,以继续无限制使用 VPN。",
|
||||||
"title": "升级订阅"
|
"title": "升级订阅"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1012,7 +1043,7 @@
|
|||||||
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。",
|
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。",
|
||||||
"selectSubscription": "请先选择订阅"
|
"selectSubscription": "请先选择订阅"
|
||||||
},
|
},
|
||||||
"payWithStars": "支付 {stars} Stars",
|
"payWithStars": "支付 Stars",
|
||||||
"processingPayment": "处理中...",
|
"processingPayment": "处理中...",
|
||||||
"starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。",
|
"starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。",
|
||||||
"starsPaymentFailed": "支付失败。请重试。",
|
"starsPaymentFailed": "支付失败。请重试。",
|
||||||
@@ -1667,7 +1698,7 @@
|
|||||||
},
|
},
|
||||||
"broadcasts": {
|
"broadcasts": {
|
||||||
"title": "广播",
|
"title": "广播",
|
||||||
"subtitle": "历史和管理",
|
"subtitle": "向用户群发消息",
|
||||||
"create": "创建群发",
|
"create": "创建群发",
|
||||||
"empty": "没有广播",
|
"empty": "没有广播",
|
||||||
"selectFilter": "选择受众",
|
"selectFilter": "选择受众",
|
||||||
@@ -2023,7 +2054,15 @@
|
|||||||
"offlineConv": "离线转化",
|
"offlineConv": "离线转化",
|
||||||
"apiKey": "API 密钥",
|
"apiKey": "API 密钥",
|
||||||
"legalFooter": "法律页脚",
|
"legalFooter": "法律页脚",
|
||||||
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接"
|
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接",
|
||||||
|
"botStartVideo": "机器人开始菜单视频",
|
||||||
|
"botStartVideoDesc": "机器人会在 /start 消息中附带该视频,替代 logo 图片。文件仅上传到 Telegram 一次,我们只保存其标识符。",
|
||||||
|
"botStartVideoUpload": "上传视频",
|
||||||
|
"botStartVideoReplace": "替换视频",
|
||||||
|
"botStartVideoRemove": "删除",
|
||||||
|
"botStartVideoActive": "已设置视频",
|
||||||
|
"botStartVideoNone": "未设置视频 — 菜单使用 logo 图片",
|
||||||
|
"botStartVideoError": "视频上传失败"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"color": "按钮颜色",
|
"color": "按钮颜色",
|
||||||
@@ -2072,7 +2111,7 @@
|
|||||||
"builtinButtons": "内置栏目",
|
"builtinButtons": "内置栏目",
|
||||||
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
|
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
|
||||||
"buttonTextPlaceholder": "按钮文本",
|
"buttonTextPlaceholder": "按钮文本",
|
||||||
"customLabelsHint": "每种语言的按钮文本",
|
"customLabelsHint": "留空 = 默认名称",
|
||||||
"moveUp": "上移",
|
"moveUp": "上移",
|
||||||
"moveDown": "下移",
|
"moveDown": "下移",
|
||||||
"openIn": "打开方式",
|
"openIn": "打开方式",
|
||||||
@@ -2344,7 +2383,9 @@
|
|||||||
"periodsRequired": "请添加至少一个期限",
|
"periodsRequired": "请添加至少一个期限",
|
||||||
"dailyPriceRequired": "请输入大于0的每日价格",
|
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||||
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
||||||
}
|
},
|
||||||
|
"lavaProductLabel": "Lava 产品(自动续订)",
|
||||||
|
"lavaProductDesc": "来自 Lava 后台的产品 UUID:价格与扣款周期在那里设置。留空则该套餐不支持自动续订。"
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "服务器管理",
|
"title": "服务器管理",
|
||||||
@@ -2624,10 +2665,10 @@
|
|||||||
"programEnabled": "推荐计划已启用",
|
"programEnabled": "推荐计划已启用",
|
||||||
"programEnabledDesc": "启用整个推荐人功能",
|
"programEnabledDesc": "启用整个推荐人功能",
|
||||||
"requisitesText": "收款信息文本",
|
"requisitesText": "收款信息文本",
|
||||||
"requisitesTextDesc": "将在合作申请表单中向用户显示",
|
"requisitesTextDesc": "自定义提示,告知用户提现时需填写的收款信息",
|
||||||
"requisitesTextPlaceholder": "请输入您的收款信息...",
|
"requisitesTextPlaceholder": "请输入您的收款信息...",
|
||||||
"withdrawalEnabled": "允许提款",
|
"withdrawalEnabled": "允许提款",
|
||||||
"withdrawalEnabledDesc": "允许用户请求提款"
|
"withdrawalEnabledDesc": "允许合作伙伴申请提现推荐余额"
|
||||||
},
|
},
|
||||||
"settingsLoadError": "加载推荐设置失败",
|
"settingsLoadError": "加载推荐设置失败",
|
||||||
"settingsSection": {
|
"settingsSection": {
|
||||||
@@ -3304,7 +3345,9 @@
|
|||||||
"notificationsFailed": "(失败: {{count}})",
|
"notificationsFailed": "(失败: {{count}})",
|
||||||
"sendError": "发送优惠失败",
|
"sendError": "发送优惠失败",
|
||||||
"notificationsSent": "已发送通知",
|
"notificationsSent": "已发送通知",
|
||||||
"offersCreated": "已创建优惠"
|
"offersCreated": "已创建优惠",
|
||||||
|
"deliveryTitle": "Telegram 送达情况",
|
||||||
|
"openAsBroadcast": "作为群发查看"
|
||||||
},
|
},
|
||||||
"notFound": "未找到模板",
|
"notFound": "未找到模板",
|
||||||
"noActiveTemplates": "没有活跃的模板",
|
"noActiveTemplates": "没有活跃的模板",
|
||||||
@@ -3406,7 +3449,7 @@
|
|||||||
"usersOnlineCount": "{{count}} 在线"
|
"usersOnlineCount": "{{count}} 在线"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "实时带宽",
|
"bandwidth": "各 inbound 流量",
|
||||||
"connections": "连接数",
|
"connections": "连接数",
|
||||||
"cpu": "CPU核心",
|
"cpu": "CPU核心",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
@@ -4939,40 +4982,48 @@
|
|||||||
},
|
},
|
||||||
"activateButton": "激活",
|
"activateButton": "激活",
|
||||||
"activateCodePlaceholder": "输入礼物代码",
|
"activateCodePlaceholder": "输入礼物代码",
|
||||||
"activatedBy": "由 {{name}} 激活",
|
"activatedBy": "由 {{username}} 激活",
|
||||||
"activateDescription": "输入您收到的礼物代码以激活订阅",
|
"activateDescription": "输入您收到的礼物代码以激活订阅",
|
||||||
"activatedGiftsTitle": "已激活的礼物",
|
"activatedGiftsTitle": "已激活的礼物",
|
||||||
"activateError": "激活礼物失败",
|
"activateError": "激活礼物失败",
|
||||||
"activateSelfError": "您不能激活自己的礼物",
|
"activateSelfError": "您不能激活自己的礼物",
|
||||||
"activateSuccess": "礼物已激活!",
|
"activateSuccess": "礼物已激活!",
|
||||||
"activateSuccessDesc": "订阅已添加到您的账户",
|
"activateSuccessDesc": "{{tariff}}:已为您的订阅添加 {{days}} 天",
|
||||||
"activateTitle": "激活礼物",
|
"activateTitle": "激活礼物",
|
||||||
"activeGiftsTitle": "活跃的礼物",
|
"activeGiftsTitle": "活跃的礼物",
|
||||||
"codeLabel": "礼物代码",
|
"codeLabel": "礼物代码",
|
||||||
"codeReadyTitle": "您的礼物代码已就绪",
|
"codeReadyTitle": "您的礼物代码已就绪",
|
||||||
"copyMessage": "复制消息",
|
"copyMessage": "复制消息",
|
||||||
"daysShort": "天",
|
"daysShort": "天",
|
||||||
"deviceCount": "设备数",
|
"deviceCount": "{{count}} 台设备",
|
||||||
"devicesShort": "台",
|
"devicesShort": "台",
|
||||||
"gbShort": "GB",
|
"gbShort": "GB",
|
||||||
"myGiftsEmpty": "暂无礼物",
|
"myGiftsEmpty": "暂无礼物",
|
||||||
"myGiftsEmptyDesc": "您发送或收到的礼物将显示在这里",
|
"myGiftsEmptyDesc": "为好友购买礼物,或激活已收到的兑换码",
|
||||||
"pageTitle": "礼物",
|
"pageTitle": "礼物",
|
||||||
"receivedGiftsTitle": "已收到的礼物",
|
"receivedGiftsTitle": "已收到的礼物",
|
||||||
"selectPeriod": "选择时长",
|
"selectPeriod": "选择时长",
|
||||||
"selectTariff": "选择套餐",
|
"selectTariff": "选择套餐",
|
||||||
"sentTo": "已发送给 {{name}}",
|
"sentTo": "已发送给 {{recipient}}",
|
||||||
"shareGift": "分享礼物",
|
"shareGift": "分享礼物",
|
||||||
"shareModalActivateVia": "通过以下方式激活",
|
"shareModalActivateVia": "通过机器人激活:",
|
||||||
"shareModalActivateViaCabinet": "通过个人中心",
|
"shareModalActivateViaCabinet": "通过个人中心",
|
||||||
"shareText": "我为您准备了一份 Bedolaga VPN 礼物:{{code}}",
|
"shareText": "我给你准备了一份 Bedolaga VPN 礼物,在这里激活它:",
|
||||||
"shareToastCopied": "链接已复制",
|
"shareToastCopied": "链接已复制",
|
||||||
"statusActivated": "已激活",
|
"statusActivated": "已激活",
|
||||||
"statusAvailable": "可激活",
|
"statusAvailable": "可激活",
|
||||||
"tabActivate": "激活",
|
"tabActivate": "激活",
|
||||||
"tabBuy": "购买",
|
"tabBuy": "购买",
|
||||||
"tabMyGifts": "我的礼物",
|
"tabMyGifts": "我的礼物",
|
||||||
"unlimitedTraffic": "无限流量"
|
"unlimitedTraffic": "无限流量",
|
||||||
|
"qrHint": "扫描以激活礼物",
|
||||||
|
"scanButton": "📷 扫描二维码",
|
||||||
|
"scanInProgress": "扫描中…",
|
||||||
|
"scanCancel": "停止扫描",
|
||||||
|
"scanDescription": "将摄像头对准礼物二维码",
|
||||||
|
"scanNotRecognized": "该二维码中没有礼物码",
|
||||||
|
"scanError": "扫描失败",
|
||||||
|
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "新闻与更新",
|
"title": "新闻与更新",
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { StatCard } from '@/components/stats';
|
|
||||||
import {
|
import {
|
||||||
BanIcon,
|
BroadcastDeliveryStats,
|
||||||
|
BroadcastStatusBadge,
|
||||||
|
} from '../components/broadcasts/BroadcastDeliveryStats';
|
||||||
|
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
|
||||||
|
import {
|
||||||
DocumentIcon,
|
DocumentIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
SendIcon,
|
|
||||||
StopIcon,
|
StopIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
UsersIcon,
|
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
XCircleIcon,
|
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
|
||||||
// Channel badge component
|
// 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() {
|
export default function AdminBroadcastDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
|
|||||||
return adminBroadcastsApi.get(broadcastId);
|
return adminBroadcastsApi.get(broadcastId);
|
||||||
},
|
},
|
||||||
enabled: !!broadcastId && !isNaN(broadcastId),
|
enabled: !!broadcastId && !isNaN(broadcastId),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
|
||||||
const data = query.state.data;
|
|
||||||
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
|
|
||||||
return 3000;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop mutation
|
// 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)) {
|
if (!broadcastId || isNaN(broadcastId)) {
|
||||||
navigate('/admin/broadcasts');
|
navigate('/admin/broadcasts');
|
||||||
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
|
|||||||
<h1 className="text-xl font-bold text-dark-100">
|
<h1 className="text-xl font-bold text-dark-100">
|
||||||
{t('admin.broadcasts.detail')} #{broadcast.id}
|
{t('admin.broadcasts.detail')} #{broadcast.id}
|
||||||
</h1>
|
</h1>
|
||||||
<StatusBadge status={broadcast.status} />
|
<BroadcastStatusBadge status={broadcast.status} />
|
||||||
<ChannelBadge channel={broadcast.channel} />
|
<ChannelBadge channel={broadcast.channel} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-dark-400">
|
<p className="text-sm text-dark-400">
|
||||||
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress + stats */}
|
||||||
{isRunning && (
|
<BroadcastDeliveryStats
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
status={broadcast.status}
|
||||||
<div className="mb-2 flex justify-between text-sm">
|
progressPercent={broadcast.progress_percent}
|
||||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
totalCount={broadcast.total_count}
|
||||||
<span className="font-medium text-dark-100">
|
sentCount={broadcast.sent_count}
|
||||||
{broadcast.progress_percent.toFixed(1)}%
|
blockedCount={broadcast.blocked_count}
|
||||||
</span>
|
failedCount={broadcast.failed_count}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Target */}
|
{/* Target */}
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createNumberInputHandler } from '../utils/inputHelpers';
|
import { createNumberInputHandler } from '../utils/inputHelpers';
|
||||||
import { couponsApi, CouponBatchCreated } from '../api/coupons';
|
import { couponsApi, type CouponBatchCreated } from '../api/coupons';
|
||||||
import { tariffsApi } from '../api/tariffs';
|
import { tariffsApi } from '../api/tariffs';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
|
|||||||
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
||||||
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
||||||
const [validDays, setValidDays] = useState<number | ''>('');
|
const [validDays, setValidDays] = useState<number | ''>('');
|
||||||
|
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
|
||||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
|
|||||||
coupons_count: Number(couponsCount),
|
coupons_count: Number(couponsCount),
|
||||||
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
||||||
valid_days: validDays === '' ? 0 : Number(validDays),
|
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>
|
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
|
|||||||
|
|
||||||
const batchId = Number(id);
|
const batchId = Number(id);
|
||||||
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const { data: batch, isLoading } = useQuery({
|
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 = () => {
|
const handleCopyAll = () => {
|
||||||
if (!links) return;
|
if (!links) return;
|
||||||
void copyToClipboard(links.links.join('\n'));
|
void copyToClipboard(links.links.join('\n'));
|
||||||
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
||||||
<span className="text-dark-100">
|
<span className="text-dark-100">
|
||||||
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
|
|||||||
</PermissionGate>
|
</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 */}
|
{/* Revoke confirmation */}
|
||||||
{revokeConfirm && (
|
{revokeConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
promoOffersApi,
|
promoOffersApi,
|
||||||
PromoOfferBroadcastRequest,
|
type PromoOfferBroadcastRequest,
|
||||||
TARGET_SEGMENTS,
|
TARGET_SEGMENTS,
|
||||||
TargetSegment,
|
type TargetSegment,
|
||||||
OFFER_TYPE_CONFIG,
|
OFFER_TYPE_CONFIG,
|
||||||
OfferType,
|
type OfferType,
|
||||||
} from '../api/promoOffers';
|
} 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 { AdminBackButton } from '../components/admin';
|
||||||
|
import {
|
||||||
|
BroadcastDeliveryStats,
|
||||||
|
BroadcastStatusBadge,
|
||||||
|
} from '../components/broadcasts/BroadcastDeliveryStats';
|
||||||
|
import { broadcastPollInterval } from '../utils/broadcastStatus';
|
||||||
import {
|
import {
|
||||||
SendIcon,
|
SendIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
isSuccess: boolean;
|
isSuccess: boolean;
|
||||||
|
broadcastId?: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Query templates
|
// Query templates
|
||||||
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
|
|||||||
queryFn: promoOffersApi.getTemplates,
|
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 templates = templatesData?.items || [];
|
||||||
const activeTemplates = templates.filter((t) => t.is_active);
|
const activeTemplates = templates.filter((t) => t.is_active);
|
||||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||||
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
|
|||||||
mutationFn: promoOffersApi.broadcastOffer,
|
mutationFn: promoOffersApi.broadcastOffer,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||||
|
|
||||||
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
|
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
|
||||||
if (data.notifications_sent > 0 || data.notifications_failed > 0) {
|
if (data.notifications_sent > 0 || data.notifications_failed > 0) {
|
||||||
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
|
|||||||
title: t('admin.promoOffers.result.sentTitle'),
|
title: t('admin.promoOffers.result.sentTitle'),
|
||||||
message,
|
message,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
|
broadcastId: data.broadcast_id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
|
|||||||
if (result) {
|
if (result) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<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
|
<div
|
||||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
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'
|
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
|
||||||
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
|
<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>
|
<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">
|
<div className="flex justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/promo-offers')}
|
onClick={() => navigate('/admin/promo-offers')}
|
||||||
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sendMode === 'segment' ? (
|
{sendMode === 'segment' ? (
|
||||||
<select
|
<>
|
||||||
value={selectedTarget}
|
<select
|
||||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
value={selectedTarget}
|
||||||
className="input"
|
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||||
>
|
className="input"
|
||||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
|
>
|
||||||
<option key={key} value={key}>
|
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
|
||||||
{t(labelKey)}
|
const count = segmentCounts.get(key);
|
||||||
</option>
|
return (
|
||||||
))}
|
<option key={key} value={key}>
|
||||||
</select>
|
{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">
|
<div ref={searchRef} className="relative">
|
||||||
{selectedUser ? (
|
{selectedUser ? (
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function AdminTariffCreate() {
|
|||||||
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
|
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
|
||||||
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||||
|
const [lavaProductId, setLavaProductId] = useState('');
|
||||||
|
|
||||||
// Traffic topup
|
// Traffic topup
|
||||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
|
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) || [],
|
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
||||||
);
|
);
|
||||||
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
||||||
|
setLavaProductId(data.lava_product_id || '');
|
||||||
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
||||||
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
||||||
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
||||||
@@ -176,6 +178,8 @@ export default function AdminTariffCreate() {
|
|||||||
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
||||||
is_daily: isDaily,
|
is_daily: isDaily,
|
||||||
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
|
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
|
||||||
|
// Пустая строка отвязывает тариф от продукта Lava
|
||||||
|
lava_product_id: lavaProductId.trim(),
|
||||||
traffic_reset_mode: trafficResetMode,
|
traffic_reset_mode: trafficResetMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
|
|||||||
</div>
|
</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 */}
|
{/* Traffic Limit */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
import { Spinner } from '@/components/ui/Spinner';
|
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>
|
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
|
||||||
</div>
|
</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 */}
|
{/* Share message preview */}
|
||||||
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
|
<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">
|
<p className="mb-3 text-sm font-medium text-dark-100">
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { uiLocale } from '@/utils/uiLocale';
|
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 { createPortal } from 'react-dom';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router';
|
import { useNavigate, useSearchParams, Link } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
|
import {
|
||||||
|
canUseTelegramScanner,
|
||||||
|
loadHtml5Qrcode,
|
||||||
|
parseGiftCode,
|
||||||
|
scanWithTelegram,
|
||||||
|
type Html5QrcodeInstance,
|
||||||
|
} from '@/utils/qrScanner';
|
||||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
import type {
|
import type {
|
||||||
GiftConfig,
|
GiftConfig,
|
||||||
@@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
|||||||
if (initialCode) setCode(initialCode);
|
if (initialCode) setCode(initialCode);
|
||||||
}, [initialCode]);
|
}, [initialCode]);
|
||||||
const [activateError, setActivateError] = useState<string | null>(null);
|
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({
|
const activateMutation = useMutation({
|
||||||
mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode),
|
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"
|
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')}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ import { saveOAuthState } from '../utils/oauth';
|
|||||||
import { getPendingReferralCode } from '../utils/referral';
|
import { getPendingReferralCode } from '../utils/referral';
|
||||||
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
|
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
|
||||||
import LegalFooter from '../components/LegalFooter';
|
import LegalFooter from '../components/LegalFooter';
|
||||||
|
import LegalConsent from '../components/LegalConsent';
|
||||||
|
import { infoApi } from '../api/info';
|
||||||
|
import type { LegalConsentConfig } from '../types';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
@@ -69,6 +72,70 @@ export default function Login() {
|
|||||||
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
||||||
const [showEmailForm, setShowEmailForm] = useState(true);
|
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
|
// Telegram safe area insets
|
||||||
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||||
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
||||||
@@ -197,6 +264,16 @@ export default function Login() {
|
|||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
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) {
|
if (status === 401 && attempt < MAX_RETRIES) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
continue;
|
continue;
|
||||||
@@ -211,7 +288,7 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tryTelegramAuth();
|
tryTelegramAuth();
|
||||||
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
|
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
|
||||||
|
|
||||||
const handleRetryTelegramAuth = () => {
|
const handleRetryTelegramAuth = () => {
|
||||||
// Clear ALL cached auth state to prevent stale token/initData loops
|
// Clear ALL cached auth state to prevent stale token/initData loops
|
||||||
@@ -264,6 +341,7 @@ export default function Login() {
|
|||||||
password,
|
password,
|
||||||
firstName || undefined,
|
firstName || undefined,
|
||||||
referralCode || undefined,
|
referralCode || undefined,
|
||||||
|
acceptedDocumentKeys,
|
||||||
);
|
);
|
||||||
// Show "check your email" screen
|
// Show "check your email" screen
|
||||||
setRegisteredEmail(result.email);
|
setRegisteredEmail(result.email);
|
||||||
@@ -273,6 +351,24 @@ export default function Login() {
|
|||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const detail = getApiErrorMessage(err, '');
|
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')) {
|
if (status === 400 && detail.includes('already registered')) {
|
||||||
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
||||||
} else if (status === 401 || status === 403) {
|
} else if (status === 401 || status === 403) {
|
||||||
@@ -376,8 +472,56 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Email Screen */}
|
{/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */}
|
||||||
{registeredEmail ? (
|
{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="card text-center">
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
|
<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" />
|
<EmailIcon className="h-7 w-7 text-success-400" />
|
||||||
@@ -696,9 +840,21 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{authMode === 'register' && (
|
||||||
|
<LegalConsent
|
||||||
|
documents={consentDocuments}
|
||||||
|
accepted={acceptedDocuments}
|
||||||
|
onChange={toggleDocument}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={
|
||||||
|
isLoading || (authMode === 'register' && !allDocumentsAccepted)
|
||||||
|
}
|
||||||
className="btn-primary w-full py-2.5"
|
className="btn-primary w-full py-2.5"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Navigate, useNavigate, useParams } from 'react-router';
|
|||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
import { getGlassColors } from '../utils/glassTheme';
|
import { getGlassColors } from '../utils/glassTheme';
|
||||||
|
import { getMonthlyPriceKopeks } from '../utils/pricing';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useHaptic } from '../platform';
|
import { useHaptic } from '../platform';
|
||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||||
@@ -143,8 +144,7 @@ export default function RenewSubscription() {
|
|||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isSelected = selectedPeriod === option.period_days;
|
const isSelected = selectedPeriod === option.period_days;
|
||||||
const canAfford = balanceKopeks >= option.price_kopeks;
|
const canAfford = balanceKopeks >= option.price_kopeks;
|
||||||
const months = Math.max(1, Math.round(option.period_days / 30));
|
const perMonth = getMonthlyPriceKopeks(option.price_kopeks, option.period_days);
|
||||||
const perMonth = option.price_kopeks / months;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -167,7 +167,7 @@ export default function RenewSubscription() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-base font-semibold" style={{ color: g.text }}>
|
<span className="text-base font-semibold" style={{ color: g.text }}>
|
||||||
{option.period_days} {t('common.units.days', 'дней')}
|
{option.period_days} {t('subscription.days', 'дней')}
|
||||||
</span>
|
</span>
|
||||||
{option.discount_percent > 0 && (
|
{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">
|
<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', 'Бесплатно')
|
? t('subscription.free', 'Бесплатно')
|
||||||
: `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`}
|
: `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`}
|
||||||
</div>
|
</div>
|
||||||
{months > 1 && (
|
{perMonth !== null && (
|
||||||
<div className="text-[11px]" style={{ color: g.textSecondary }}>
|
<div className="text-[11px]" style={{ color: g.textSecondary }}>
|
||||||
{formatAmount(perMonth / 100)} {currencySymbol}/
|
{formatAmount(perMonth / 100)} {currencySymbol}/
|
||||||
{t('common.units.mo', 'мес')}
|
{t('subscription.month', 'мес')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{option.original_price_kopeks && (
|
{option.original_price_kopeks && (
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ import {
|
|||||||
sbpUiState,
|
sbpUiState,
|
||||||
type SbpUiState,
|
type SbpUiState,
|
||||||
} from '../utils/sbpRecurring';
|
} from '../utils/sbpRecurring';
|
||||||
|
import {
|
||||||
|
isLavaFeatureDisabledError,
|
||||||
|
lavaPeriodLabelKey,
|
||||||
|
lavaUiState,
|
||||||
|
type LavaUiState,
|
||||||
|
} from '../utils/lavaRecurring';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||||||
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
||||||
@@ -375,6 +381,78 @@ export default function Subscription() {
|
|||||||
cancelSbpMutation.mutate();
|
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({
|
const autopayMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) =>
|
mutationFn: (enabled: boolean) =>
|
||||||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||||
@@ -1280,6 +1358,128 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -292,6 +292,12 @@ export default function SubscriptionPurchase() {
|
|||||||
'platega_recurrent_enabled' in purchaseOptions &&
|
'platega_recurrent_enabled' in purchaseOptions &&
|
||||||
purchaseOptions.platega_recurrent_enabled === true
|
purchaseOptions.platega_recurrent_enabled === true
|
||||||
}
|
}
|
||||||
|
lavaPurchaseEnabled={
|
||||||
|
isTariffsMode &&
|
||||||
|
purchaseOptions !== undefined &&
|
||||||
|
'lava_recurrent_enabled' in purchaseOptions &&
|
||||||
|
purchaseOptions.lava_recurrent_enabled === true
|
||||||
|
}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setShowTariffPurchase(false);
|
setShowTariffPurchase(false);
|
||||||
setSelectedTariff(null);
|
setSelectedTariff(null);
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ interface AuthState {
|
|||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
checkAdminStatus: () => Promise<void>;
|
checkAdminStatus: () => Promise<void>;
|
||||||
loginWithTelegram: (initData: string) => Promise<void>;
|
loginWithTelegram: (initData: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
loginWithTelegramWidget: (
|
||||||
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
|
data: TelegramWidgetData,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
|
) => Promise<void>;
|
||||||
|
loginWithTelegramOIDC: (idToken: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||||
loginWithOAuth: (
|
loginWithOAuth: (
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -64,6 +67,7 @@ interface AuthState {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName?: string,
|
firstName?: string,
|
||||||
referralCode?: string,
|
referralCode?: string,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
) => Promise<RegisterResponse>;
|
) => Promise<RegisterResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +265,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return initState.promise;
|
return initState.promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegram: async (initData) => {
|
loginWithTelegram: async (initData, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
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
|
// Clear only after successful auth — retry keeps the slugs
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
@@ -279,10 +288,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegramWidget: async (data) => {
|
loginWithTelegramWidget: async (data, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
const referralCode = getPendingReferralCode();
|
||||||
const response = await authApi.loginTelegramWidget(data, campaignSlug, referralCode);
|
const response = await authApi.loginTelegramWidget(
|
||||||
|
data,
|
||||||
|
campaignSlug,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
);
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
@@ -296,10 +310,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegramOIDC: async (idToken) => {
|
loginWithTelegramOIDC: async (idToken, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
const referralCode = getPendingReferralCode();
|
||||||
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
|
const response = await authApi.loginTelegramOIDC(
|
||||||
|
idToken,
|
||||||
|
campaignSlug,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
);
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
@@ -370,7 +389,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
registerWithEmail: async (
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
) => {
|
||||||
const code = referralCode || getPendingReferralCode() || undefined;
|
const code = referralCode || getPendingReferralCode() || undefined;
|
||||||
const campaignSlug = getPendingCampaignSlug() || undefined;
|
const campaignSlug = getPendingCampaignSlug() || undefined;
|
||||||
const response = await authApi.registerEmailStandalone({
|
const response = await authApi.registerEmailStandalone({
|
||||||
@@ -380,6 +405,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
language: navigator.language.split('-')[0] || 'ru',
|
language: navigator.language.split('-')[0] || 'ru',
|
||||||
referral_code: code,
|
referral_code: code,
|
||||||
campaign_slug: campaignSlug,
|
campaign_slug: campaignSlug,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions {
|
|||||||
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
|
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
|
||||||
// автооплатой СБП» рядом с покупкой с баланса
|
// автооплатой СБП» рядом с покупкой с баланса
|
||||||
platega_recurrent_enabled?: boolean;
|
platega_recurrent_enabled?: boolean;
|
||||||
|
lava_recurrent_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassicPurchaseOptions {
|
export interface ClassicPurchaseOptions {
|
||||||
@@ -528,6 +529,14 @@ export interface TicketDetail extends Omit<Ticket, 'messages_count' | 'last_mess
|
|||||||
messages: TicketMessage[];
|
messages: TicketMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гейт согласия с офертой/политикой на экране первой авторизации.
|
||||||
|
// documents — ключи документов, которые бэк реально требует отметить.
|
||||||
|
export interface LegalConsentConfig {
|
||||||
|
required: boolean;
|
||||||
|
prechecked: boolean;
|
||||||
|
documents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SupportConfig {
|
export interface SupportConfig {
|
||||||
tickets_enabled: boolean;
|
tickets_enabled: boolean;
|
||||||
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
||||||
@@ -661,6 +670,18 @@ export interface SbpRecurringInfo {
|
|||||||
redirect_url?: string | null;
|
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
|
// Ticket notifications types
|
||||||
export interface TicketNotification {
|
export interface TicketNotification {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
27
src/utils/broadcastStatus.test.ts
Normal file
27
src/utils/broadcastStatus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/utils/broadcastStatus.ts
Normal file
13
src/utils/broadcastStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
56
src/utils/lavaRecurring.test.ts
Normal file
56
src/utils/lavaRecurring.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/utils/lavaRecurring.ts
Normal file
77
src/utils/lavaRecurring.ts
Normal 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
26
src/utils/pricing.test.ts
Normal 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
15
src/utils/pricing.ts
Normal 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);
|
||||||
|
}
|
||||||
30
src/utils/qrScanner.test.ts
Normal file
30
src/utils/qrScanner.test.ts
Normal 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
106
src/utils/qrScanner.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user