Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1223f8c57 | ||
|
|
81f5168726 | ||
|
|
4de730bf5a | ||
|
|
d15b1be3b9 | ||
|
|
4d0241a0ea | ||
|
|
2c1e86863d | ||
|
|
16b11327ee | ||
|
|
3d5998b8fb | ||
|
|
2df4d2d770 | ||
|
|
4ce78f277e | ||
|
|
d7d5c2b1ef | ||
|
|
aabc0e312d | ||
|
|
d15d58df36 | ||
|
|
21f2ac4534 | ||
|
|
dd0e6e75af | ||
|
|
0251f939d7 | ||
|
|
a00bc0f345 | ||
|
|
ae4ce43e1c |
@@ -28,6 +28,7 @@ tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
+6
-2
@@ -37,14 +37,18 @@ TRUSTED_PROXIES=127.0.0.1,::1 #
|
||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="Моя подписка" # Mini App title
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
|
||||
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
|
||||
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
|
||||
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
|
||||
|
||||
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
|
||||
SMTP_HOST=smtp-relay.brevo.com # SMTP server
|
||||
|
||||
@@ -12,7 +12,10 @@ node_modules/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
tmp
|
||||
.claude
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
@@ -27,3 +30,5 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
config/tariffs.json
|
||||
docker-compose-dev.yml
|
||||
|
||||
@@ -16,6 +16,7 @@ COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY bot/app/web/frontend ./bot/app/web/frontend
|
||||
COPY bot/app/web/templates ./bot/app/web/templates
|
||||
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
|
||||
|
||||
@@ -36,9 +37,12 @@ COPY . .
|
||||
|
||||
# Replace template assets with freshly built ones
|
||||
RUN rm -f bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
|
||||
bot/app/web/templates/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Remnawave Minishop
|
||||
|
||||
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, Telegram Login Widget и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
|
||||
Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для автоматизации продажи и управления подписками панели **Remnawave**. Бот закрывает сценарий покупки, продления и работы с поддержкой прямо в чате, а Web App в едином интерфейсе показывает ссылку подключения, остаток времени, трафик, оплату и устройства, поддерживая вход через Telegram Mini Apps `initData`, новый Telegram OAuth / OpenID Connect Login и одноразовый код по email. Под капотом — интеграция с API Remnawave для управления пользователями и подписками и набор платёжных шлюзов для приёма платежей.
|
||||
|
||||
> 🍴 **Это глубоко переработанный форк [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop).** Здесь добавлены полноценный Web App / Mini App, вход по email и многое другое. Возможна миграция.
|
||||
|
||||
@@ -78,6 +78,9 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
||||
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
|
||||
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` |
|
||||
| `TELEGRAM_OAUTH_CLIENT_ID` | Client ID для нового Telegram OAuth / OpenID Connect Login из BotFather. Если пусто, используется числовой ID из `BOT_TOKEN`. | `1234567890` |
|
||||
| `TELEGRAM_OAUTH_CLIENT_SECRET` | Client Secret из BotFather для Telegram OAuth Authorization Code Flow. | `tg_oauth_secret` |
|
||||
| `TELEGRAM_OAUTH_REQUEST_ACCESS` | Дополнительные разрешения Telegram Login через запятую: `write`, `phone`. Пустое значение запрашивает только OpenID profile. | `write` |
|
||||
| `SMTP_HOST` | SMTP-сервер для кодов входа по email. Для Brevo: `smtp-relay.brevo.com`. | `smtp-relay.brevo.com` |
|
||||
| `SMTP_PORT` | SMTP-порт. Для Brevo обычно используется 587 с STARTTLS. | `587` |
|
||||
| `SMTP_FALLBACK_PORTS` | Дополнительные SMTP-порты через запятую. Пробуются после `SMTP_PORT`; порт `465` автоматически используется через SSL. Для Brevo удобно оставить `2525,465`. | `2525,465` |
|
||||
@@ -140,13 +143,11 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
<summary><b>Настройки тарифов</b></summary>
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
Бот умеет продавать **подписку на срок** (1/3/6/12 мес.) или **пакеты трафика** (`TRAFFIC_PACKAGES=10:199,50:799`). Эти режимы взаимоисключающие — наличие непустой `TRAFFIC_PACKAGES` (или `STARS_TRAFFIC_PACKAGES`) автоматически переключает бот в режим продажи трафика.
|
||||
|
||||
Полное описание обоих режимов, переменных, что происходит при покупке, как ведут себя автопродление, реф-бонусы и триал — вынесено в [docs/tariffs.md](docs/tariffs.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -237,7 +238,7 @@ Remnawave Minishop — это Telegram-бот **и** Web App (Mini App) для
|
||||
|
||||
### Настройка Web App / Mini App
|
||||
|
||||
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
|
||||
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Telegram пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется новый Telegram OAuth / OpenID Connect Authorization Code Flow с PKCE, callback `/auth/telegram/callback`, `nonce` и серверной проверкой `id_token` по JWKS Telegram. Старый Login Widget больше не используется в UI. Также доступен вход по email через одноразовый код из письма, если настроен SMTP: после отправки письма код вводится в отдельном модальном окне подтверждения. После успешного входа страница обновляет данные сразу, без сообщений боту.
|
||||
|
||||
1. Укажите в `.env` публичный URL Web App и порт:
|
||||
|
||||
@@ -249,6 +250,9 @@ Web App запускается в том же контейнере, что и б
|
||||
WEBAPP_TITLE="Моя подписка"
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a"
|
||||
WEBAPP_LOGO_URL=
|
||||
TELEGRAM_OAUTH_CLIENT_ID=<client-id-из-botfather>
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET=<client-secret-из-botfather>
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS=write
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_FALLBACK_PORTS=2525,465
|
||||
@@ -293,7 +297,17 @@ Web App запускается в том же контейнере, что и б
|
||||
}
|
||||
```
|
||||
|
||||
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget.
|
||||
4. В BotFather настройте бота, Mini App и Telegram OAuth Login:
|
||||
|
||||
- `@BotFather` → `/mybots` → выберите бота.
|
||||
- **Bot Settings → Domain**: укажите домен без протокола и пути, например `app.domain.com`.
|
||||
- **Bot Settings → Mini Apps**: задайте URL Mini App, например `https://app.domain.com/`.
|
||||
- **Bot Settings → Web Login**: если BotFather показывает кнопку `Switch to OpenID Connect Login`, нажмите ее.
|
||||
- **Bot Settings → Web Login**: скопируйте Client ID и Client Secret в `TELEGRAM_OAUTH_CLIENT_ID` и `TELEGRAM_OAUTH_CLIENT_SECRET`.
|
||||
- **Web Login → Allowed URLs**: добавьте:
|
||||
`https://app.domain.com/`
|
||||
`https://app.domain.com/auth/telegram/callback`
|
||||
- `TELEGRAM_OAUTH_REQUEST_ACCESS=write` разрешает боту написать пользователю после логина. Если дополнительные разрешения не нужны, оставьте переменную пустой.
|
||||
|
||||
5. Перезапустите контейнер:
|
||||
|
||||
@@ -301,23 +315,12 @@ Web App запускается в том же контейнере, что и б
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Login Widget. После привязки вход работает обоими способами.
|
||||
После этого кнопка «Личный кабинет» в меню бота откроет Web App. Рядом доступна кнопка «Бот-меню» для открытия расширенного интерфейса в чате без команды `/tg`, но основной сценарий управления подпиской удобнее проходить в личном кабинете. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Telegram OAuth Login. После привязки вход работает обоими способами.
|
||||
|
||||
Реферальные ссылки доступны в двух форматах: Telegram deep-link `https://t.me/<bot>?start=ref_u<code>` и Web App ссылка с query-параметром `ref=u<code>`. Web App учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передаёт его в Telegram OAuth и email-вход, чтобы регистрация корректно привязалась к пригласившему.
|
||||
|
||||
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
|
||||
|
||||
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
|
||||
|
||||
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
|
||||
|
||||
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
|
||||
|
||||
```bash
|
||||
python scripts/update_telegram_web_app_js.py
|
||||
python scripts/update_telegram_widget_js.py
|
||||
```
|
||||
|
||||
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
|
||||
|
||||
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
<script>
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Copy,
|
||||
Crown,
|
||||
Database,
|
||||
Download,
|
||||
Gift,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Send,
|
||||
Ticket,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from "lucide-svelte";
|
||||
|
||||
import Button from "./lib/components/ui/button.svelte";
|
||||
import Card from "./lib/components/ui/card.svelte";
|
||||
import BackTitle from "./preview/BackTitle.svelte";
|
||||
import PhoneFrame from "./preview/PhoneFrame.svelte";
|
||||
import PreviewMethods from "./preview/PreviewMethods.svelte";
|
||||
import PreviewNav from "./preview/PreviewNav.svelte";
|
||||
|
||||
export let config = {};
|
||||
export let mockData = {};
|
||||
|
||||
const title = config.title || "/minishop";
|
||||
const logoEmoji = config.logoEmoji || "🫥";
|
||||
const plans = mockData.plans || [];
|
||||
const sub = mockData.subscription || {};
|
||||
const methods = mockData.payment_methods || [];
|
||||
const user = mockData.user || {};
|
||||
const tariffs = [
|
||||
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
|
||||
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
|
||||
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
|
||||
];
|
||||
const traffic = [
|
||||
[20, 290],
|
||||
[50, 590],
|
||||
[100, 990],
|
||||
[300, 2190],
|
||||
];
|
||||
const settingsRows = [
|
||||
[Globe2, "Язык интерфейса", "Русский"],
|
||||
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
|
||||
[Mail, "Привязка почты", user.email || "Не привязана"],
|
||||
[UserRound, "Выйти", "Завершить сессию"],
|
||||
];
|
||||
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
|
||||
const previewEmail = user.email || "Почта не привязана";
|
||||
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
|
||||
const previewAvatar = user.telegram_photo_url || "";
|
||||
|
||||
function money(value) {
|
||||
return `${value} ₽`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
|
||||
<PhoneFrame number="1" label="Главный экран">
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<div class="home-bottom">
|
||||
<Card class="status-card">
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 size={23} />
|
||||
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
|
||||
<div class="progress"><span style="width: 18%"></span></div>
|
||||
<div class="traffic-percent">18%</div>
|
||||
</Card>
|
||||
<div class="action-stack">
|
||||
<Button class="wide"><Download size={17} />Установить и настроить</Button>
|
||||
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
|
||||
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewNav active="home" />
|
||||
</main>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="2" label="Выбор тарифа">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<div class="tariff-list">
|
||||
{#each tariffs as tariff, index}
|
||||
<div class:active={index === 0} class="select-card">
|
||||
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
|
||||
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
|
||||
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
|
||||
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
|
||||
<div class="period-grid">
|
||||
{#each plans as plan, index}
|
||||
<div class:active={index === 1} class="period-card">
|
||||
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small>{money(Math.round(plan.price / plan.months))}/мес</small>
|
||||
{#if index === 1}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
|
||||
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
|
||||
<div class="period-grid">
|
||||
{#each traffic as pack, index}
|
||||
<div class:active={index === 2} class="period-card">
|
||||
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small>{money(Math.round(pack[1] / pack[0]))}/ГБ</small>
|
||||
{#if index === 2}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="5" label="Смена тарифа">
|
||||
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
|
||||
<div class="tariff-list compact">
|
||||
<div class="select-card"><span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em><Circle size={20} /></div>
|
||||
<div class="select-card active"><span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em>Доплата не требуется</em><CheckCircle2 size={20} /></div>
|
||||
<div class="select-card"><span><strong>Премиум</strong><small>Максимальная скорость</small></span><em>Доплата 390 ₽</em><Circle size={20} /></div>
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
<div class="preview-modal">
|
||||
<Repeat2 size={30} />
|
||||
<strong>Сменить тариф без доплаты?</strong>
|
||||
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
|
||||
<Button>Да, сменить</Button>
|
||||
<Button variant="secondary">Отмена</Button>
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="6" label="Пригласить друга">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card>
|
||||
<div class="card-label">Ваша реферальная ссылка</div>
|
||||
<div class="copy-row"><code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button></div>
|
||||
</Card>
|
||||
<Card class="bonus-card">
|
||||
<Gift size={42} /><div><span>Ваш бонус</span><strong>+7 дней за каждого друга</strong><p>Друг получит +3 дня к подписке.</p></div>
|
||||
</Card>
|
||||
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="7" label="Настройки">
|
||||
<div class="preview-header"><div class="brand-row"><div class="brand-mark"><span>{logoEmoji}</span></div><strong>{title}</strong></div></div>
|
||||
<Card class="settings-profile">
|
||||
<div class="settings-avatar">
|
||||
{#if previewAvatar}
|
||||
<img src={previewAvatar} alt="Аватар пользователя" />
|
||||
{:else}
|
||||
<UserRound size={27} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="settings-profile-meta">
|
||||
<strong>{previewTelegramName}</strong>
|
||||
<small>{previewEmail}</small>
|
||||
<small>{previewTelegramId}</small>
|
||||
</div>
|
||||
</Card> <div class="settings-list">
|
||||
{#each settingsRows as row}
|
||||
<div class="settings-row">
|
||||
<svelte:component this={row[0]} size={20} />
|
||||
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="8" label="Логин" wide>
|
||||
<div class="login-brand small">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1><p>Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
<div class="field-label">Вход по email</div>
|
||||
<div class="auth-email-stack"><div class="input muted">Email</div><Button class="wide"><Mail size={17} />Войти по почте</Button></div>
|
||||
<div class="or-line"><span></span>или<span></span></div>
|
||||
<Button variant="telegram" class="wide telegram-login-button">
|
||||
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
|
||||
</Button>
|
||||
</Card>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="9" label="Подтверждение по коду" wide>
|
||||
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
|
||||
<div class="otp-slots static">{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}</div>
|
||||
<Button class="wide bottom-action">Подтвердить</Button>
|
||||
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let type = "button";
|
||||
export let variant = "default";
|
||||
export let size = "default";
|
||||
export let disabled = false;
|
||||
export let href = "";
|
||||
export let onclick = undefined;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const variants = {
|
||||
default: "btn btn-primary",
|
||||
secondary: "btn btn-secondary",
|
||||
outline: "btn btn-outline",
|
||||
ghost: "btn btn-ghost",
|
||||
telegram: "btn btn-telegram",
|
||||
icon: "btn btn-icon",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: "",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-square",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let active = false;
|
||||
export let compact = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
|
||||
<slot />
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { X } from "lucide-svelte";
|
||||
import { cn } from "../../utils.js";
|
||||
import Button from "./button.svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let description = "";
|
||||
export let closeLabel = "Close";
|
||||
export let onclose = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button>
|
||||
<section class={cn("dialog-card", className)}>
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</div>
|
||||
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { cn } from "../../utils.js";
|
||||
|
||||
export let value = "";
|
||||
export let type = "text";
|
||||
export let placeholder = "";
|
||||
export let inputmode = undefined;
|
||||
export let maxlength = undefined;
|
||||
export let autocomplete = undefined;
|
||||
export let disabled = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn("input", className)}
|
||||
on:keydown
|
||||
on:input
|
||||
on:focus
|
||||
on:blur
|
||||
{type}
|
||||
{placeholder}
|
||||
{inputmode}
|
||||
{maxlength}
|
||||
{autocomplete}
|
||||
{disabled}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mount } from "svelte";
|
||||
|
||||
import App from "./App.svelte";
|
||||
import "./styles.css";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
mount(App, { target });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { ArrowLeft } from "lucide-svelte";
|
||||
|
||||
export let title = "";
|
||||
export let subtitle = "";
|
||||
</script>
|
||||
|
||||
<header class="screen-head">
|
||||
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div class="center-copy">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<span></span>
|
||||
</header>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
export let number = "";
|
||||
export let label = "";
|
||||
export let wide = false;
|
||||
</script>
|
||||
|
||||
<section class:wide class="preview-phone-wrap">
|
||||
<h2><span>{number}.</span> {label}</h2>
|
||||
<div class="preview-phone">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { CreditCard, Send, WalletCards } from "lucide-svelte";
|
||||
|
||||
export let methods = [];
|
||||
|
||||
const icons = [CreditCard, Send, WalletCards, WalletCards];
|
||||
|
||||
function note(index) {
|
||||
if (index === 0) return "Visa, Mastercard";
|
||||
if (index === 1) return "Быстро и удобно";
|
||||
if (index === 2) return "USDT, BTC, ETH";
|
||||
return "ЮMoney, СБП и др.";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="method-grid">
|
||||
{#each methods as method, index}
|
||||
<div class:active={index === 1} class="method-card">
|
||||
<svelte:component this={icons[index] || WalletCards} size={18} />
|
||||
<span>
|
||||
<strong>{method.name}</strong>
|
||||
<small>{note(index)}</small>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { Gift, Home, Settings } from "lucide-svelte";
|
||||
|
||||
export let active = "home";
|
||||
</script>
|
||||
|
||||
<nav class="bottom-nav static">
|
||||
<button class:active={active === "home"} type="button"><Home size={20} /><span>Главная</span></button>
|
||||
<button class:active={active === "invite"} type="button"><Gift size={20} /><span>Пригласить</span></button>
|
||||
<button class:active={active === "settings"} type="button"><Settings size={20} /><span>Настройки</span></button>
|
||||
</nav>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const templateDir = path.resolve(__dirname, "../templates");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: templateDir,
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, "src/main.js"),
|
||||
name: "SubscriptionWebApp",
|
||||
formats: ["iife"],
|
||||
fileName: () => "subscription_webapp.js",
|
||||
cssFileName: "subscription_webapp",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
|
||||
return "subscription_webapp.css";
|
||||
}
|
||||
return "subscription_webapp.[name][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+986
-265
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,292 +5,25 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#05070a">
|
||||
<meta name="theme-color" content="#03070b">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>Моя подписка</title>
|
||||
<title>/minishop</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
src: url('https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
<script src="./telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="./subscription_webapp.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
|
||||
|
||||
<main id="app" class="app hidden">
|
||||
<header class="app-header">
|
||||
<div class="app-header-title">
|
||||
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="lang-menu">
|
||||
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
|
||||
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
|
||||
<span id="lang-chip-label" class="lang-chip-label">RU</span>
|
||||
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
|
||||
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
|
||||
<span id="user-chip-name" class="user-chip-name">...</span>
|
||||
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
|
||||
<div class="user-dropdown-head">
|
||||
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
|
||||
<div class="user-dropdown-head-text">
|
||||
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
|
||||
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-rows">
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
|
||||
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
|
||||
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||
</svg>
|
||||
<span data-i18n="logout">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel grid gap-[17px] p-[18px]">
|
||||
<div id="sub-status" class="sub-status">
|
||||
<span class="sub-status-dot" aria-hidden="true"></span>
|
||||
<span id="sub-status-text" class="sub-status-text">...</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-countdown">
|
||||
<div class="sub-countdown-row">
|
||||
<span id="remaining" class="sub-countdown-value">...</span>
|
||||
<span id="end-date-caption" class="sub-countdown-caption"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
|
||||
<span id="traffic" class="traffic-bar-value">...</span>
|
||||
</div>
|
||||
|
||||
<div id="connect-actions">
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
|
||||
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="email-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
|
||||
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
|
||||
</div>
|
||||
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="telegram-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
|
||||
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
|
||||
</div>
|
||||
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid min-w-0 grid-cols-2 gap-2">
|
||||
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
|
||||
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="select_period">Выберите период</div>
|
||||
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
|
||||
</div>
|
||||
|
||||
<div class="notice grid gap-1.5">
|
||||
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
|
||||
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
|
||||
<div id="payment-methods" class="grid gap-2"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
|
||||
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
|
||||
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
|
||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
|
||||
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
|
||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
|
||||
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||
<div id="email-code-address" class="metric-value">...</div>
|
||||
</div>
|
||||
|
||||
<label class="relative block min-h-14 cursor-text">
|
||||
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
|
||||
<span class="otp-slots" aria-hidden="true">
|
||||
<span id="email-code-slot-0"></span>
|
||||
<span id="email-code-slot-1"></span>
|
||||
<span id="email-code-slot-2"></span>
|
||||
<span id="email-code-slot-3"></span>
|
||||
<span id="email-code-slot-4"></span>
|
||||
<span id="email-code-slot-5"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="login" class="login login-layout gap-5">
|
||||
<div class="grid justify-items-center gap-3 text-center">
|
||||
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
|
||||
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||
</div>
|
||||
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="panel-modal w-full grid gap-5 p-6 text-center">
|
||||
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
|
||||
<div class="grid gap-2.5">
|
||||
<div class="auth-tabs-wrapper" role="tablist">
|
||||
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
|
||||
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
|
||||
</div>
|
||||
<div class="login-auth-body">
|
||||
<div id="email-login-pane" class="grid gap-2.5">
|
||||
<div class="grid gap-2.5">
|
||||
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
||||
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="legal-links-login" data-legal-links class="legal-links hidden">
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||
<main id="app"></main>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<!-- WEBAPP_JS_SCRIPT -->
|
||||
<!-- WEBAPP_DEV_MOCK_START -->
|
||||
<script src="/subscription_webapp.js" defer></script>
|
||||
<!-- WEBAPP_DEV_MOCK_END -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,907 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "./subscription_webapp.html";
|
||||
@source "./subscription_webapp.js";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #05070a;
|
||||
--bg-card: #0f1521;
|
||||
--bg-card-hover: #161e2e;
|
||||
--border: #1e2940;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #00fe7a;
|
||||
--accent-hover: #5aff9f;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 8px;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--app-safe-top-extra: 10px;
|
||||
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
|
||||
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
|
||||
}
|
||||
|
||||
body {
|
||||
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer border-0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply cursor-progress opacity-[0.58];
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.btn-sm {
|
||||
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
.metrics {
|
||||
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
|
||||
}
|
||||
.metric {
|
||||
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
|
||||
}
|
||||
.auth-tab {
|
||||
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
|
||||
}
|
||||
.login-layout {
|
||||
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
|
||||
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
|
||||
padding-bottom: max(var(--app-safe-bottom), 18px);
|
||||
}
|
||||
.section-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.app-loader {
|
||||
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.auth-tabs-wrapper {
|
||||
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
|
||||
}
|
||||
|
||||
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.plan-card {
|
||||
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.plan-card-active {
|
||||
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
||||
}
|
||||
.payment-method-card {
|
||||
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.payment-method-card--platega {
|
||||
border-color: rgba(0, 254, 122, 0.36);
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
.payment-method-card--cryptopay {
|
||||
border-color: rgba(34, 211, 238, 0.36);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.lang-chip {
|
||||
@apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.lang-chip-flag {
|
||||
@apply text-[14px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-chip-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide;
|
||||
}
|
||||
|
||||
.lang-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.lang-chip[aria-expanded="true"] .lang-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.lang-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.lang-dropdown-item {
|
||||
@apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item + .lang-dropdown-item {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.lang-dropdown-item.is-active {
|
||||
@apply bg-[var(--bg-card-hover)] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.lang-dropdown-flag {
|
||||
@apply text-[16px] leading-none;
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
|
||||
.lang-dropdown-label {
|
||||
@apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip-avatar {
|
||||
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] .user-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||
backdrop-filter: blur(14px);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.user-dropdown.show {
|
||||
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||
}
|
||||
|
||||
.user-dropdown-head {
|
||||
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-avatar {
|
||||
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-dropdown-head-text {
|
||||
@apply grid min-w-0 gap-0.5;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-sub {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-rows {
|
||||
@apply grid;
|
||||
}
|
||||
|
||||
.user-dropdown-row {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-row + .user-dropdown-row {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.linked {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.unlinked {
|
||||
@apply text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout {
|
||||
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout svg {
|
||||
@apply h-4 w-4 fill-current;
|
||||
}
|
||||
|
||||
.sub-status {
|
||||
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
|
||||
}
|
||||
|
||||
.sub-status.active {
|
||||
@apply text-[var(--success)];
|
||||
}
|
||||
|
||||
.sub-status-dot {
|
||||
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
|
||||
.sub-status-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.sub-status-text {
|
||||
@apply font-extrabold text-[15px] leading-tight;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
@keyframes brand-logo-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-countdown {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.sub-countdown-row {
|
||||
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
|
||||
}
|
||||
|
||||
.sub-countdown-value {
|
||||
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.sub-countdown-caption {
|
||||
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.traffic-bar {
|
||||
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.traffic-bar-fill {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
width: 0%;
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
|
||||
}
|
||||
|
||||
.traffic-bar.over .traffic-bar-fill {
|
||||
background: color-mix(in srgb, var(--danger) 32%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-fill {
|
||||
width: 100%;
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar-value {
|
||||
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
|
||||
color: #ffffff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-value {
|
||||
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes traffic-infinity-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.link-cta-head {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.link-cta-title {
|
||||
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.link-cta-caption {
|
||||
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
@keyframes sub-status-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
|
||||
}
|
||||
|
||||
.input-code {
|
||||
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
|
||||
}
|
||||
|
||||
.panel-modal {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
|
||||
}
|
||||
|
||||
.step-box {
|
||||
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
|
||||
.modal-backdrop,
|
||||
.tools-modal-backdrop {
|
||||
@apply absolute inset-0 border-0 p-0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.tools-modal-backdrop {
|
||||
@apply fixed z-25;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
|
||||
.app {
|
||||
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
||||
}
|
||||
|
||||
@media (min-width: 561px) {
|
||||
.app {
|
||||
@apply min-h-screen justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply flex min-w-0 items-center;
|
||||
}
|
||||
|
||||
.brand-logo-shell {
|
||||
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@apply absolute inset-0 h-full w-full object-contain;
|
||||
}
|
||||
|
||||
.brand-logo-spinner {
|
||||
@apply h-5 w-5 flex-none rounded-full border-2;
|
||||
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
||||
border-top-color: var(--accent);
|
||||
animation: brand-logo-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[84px] w-[84px];
|
||||
}
|
||||
|
||||
.plans,
|
||||
.methods,
|
||||
.metrics,
|
||||
.referral-link-list,
|
||||
.bonus-list {
|
||||
@apply grid min-w-0;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.otp-slots,
|
||||
.metric {
|
||||
@apply grid min-w-0 gap-2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.metric,
|
||||
.notice,
|
||||
.otp-slots span {
|
||||
@apply border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-12;
|
||||
}
|
||||
|
||||
.metric + .metric {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.brand-title,
|
||||
.login-brand-title {
|
||||
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply text-left text-2xl leading-[1.1];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply grid w-full min-h-[140px] gap-2.5 content-start;
|
||||
}
|
||||
|
||||
.login-auth-body--telegram {
|
||||
@apply content-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply font-extrabold;
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.badge {
|
||||
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mono-pill {
|
||||
@apply text-[var(--accent)];
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
|
||||
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.badge.off,
|
||||
.promo-status.error,
|
||||
.login-status.error {
|
||||
@apply text-[#fca5a5];
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.promo-status.success {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
#legal-links-login {
|
||||
@apply w-[min(100vw,560px)];
|
||||
}
|
||||
|
||||
.legal-link,
|
||||
.code-modal-resend {
|
||||
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
@apply inline flex-initial;
|
||||
}
|
||||
|
||||
|
||||
.otp-input {
|
||||
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply grid-cols-6;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
|
||||
background: rgba(5, 8, 13, 0.42);
|
||||
}
|
||||
|
||||
.otp-slots span.filled {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
|
||||
}
|
||||
|
||||
.otp-slots span.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.step-num,
|
||||
.step-name {
|
||||
@apply block text-current;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
|
||||
}
|
||||
|
||||
.step-name {
|
||||
@apply mt-1 text-xs font-extrabold leading-[1.15];
|
||||
}
|
||||
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
@apply border-[var(--accent)] text-[var(--accent)];
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.step.done {
|
||||
@apply text-[var(--text-secondary)];
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.login-layout.show {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply min-h-14 justify-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply m-0 text-sm leading-[1.45];
|
||||
}
|
||||
|
||||
.promo-status {
|
||||
@apply mt-0.5 min-h-[22px];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
@apply m-0 min-h-5;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
bottom: max(var(--app-safe-bottom), 22px);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: rgba(15, 21, 33, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 14px) scale(0.94);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@apply opacity-100;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.legal-link:hover:not(:disabled),
|
||||
.code-modal-resend:hover:not(:disabled) {
|
||||
@apply text-[var(--accent)];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.app {
|
||||
@apply px-2.5;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply max-w-[164px];
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mono-pill {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply gap-1.5;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[68px] w-[68px];
|
||||
}
|
||||
|
||||
.brand-logo-shell--lg {
|
||||
@apply h-[96px] w-[96px];
|
||||
}
|
||||
|
||||
.login-auth-body {
|
||||
@apply min-h-[128px];
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply max-w-[200px] text-[21px];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-title {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.step-box {
|
||||
@apply min-h-[54px] px-[7px] py-2;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply place-items-center p-0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal--page {
|
||||
@apply place-items-stretch p-0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.modal--page .modal-backdrop {
|
||||
@apply bg-[var(--bg-primary)] opacity-100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal--page .modal-card {
|
||||
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,575 +0,0 @@
|
||||
(function(window) {
|
||||
(function(window){
|
||||
window.__parseFunction = function(__func, __attrs) {
|
||||
__attrs = __attrs || [];
|
||||
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
|
||||
return window.execScript ? window.execScript(__func) : eval(__func);
|
||||
}
|
||||
}(window));
|
||||
(function(window){
|
||||
|
||||
function addEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener(events[i], handler);
|
||||
} else {
|
||||
el.attachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function removeEvent(el, event, handler) {
|
||||
var events = event.split(/\s+/);
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (el.removeEventListener) {
|
||||
el.removeEventListener(events[i], handler);
|
||||
} else {
|
||||
el.detachEvent('on' + events[i], handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
function getCssProperty(el, prop) {
|
||||
if (window.getComputedStyle) {
|
||||
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
|
||||
} else if (el.currentStyle) {
|
||||
return el.currentStyle[prop] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function geById(el_or_id) {
|
||||
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
|
||||
return document.getElementById(el_or_id);
|
||||
} else if (el_or_id instanceof HTMLElement) {
|
||||
return el_or_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var getWidgetsOrigin = function(default_origin, dev_origin) {
|
||||
var link = document.createElement('A'), origin;
|
||||
link.href = document.currentScript && document.currentScript.src || default_origin;
|
||||
origin = link.origin || link.protocol + '//' + link.hostname;
|
||||
if (origin == 'https://telegram.org') {
|
||||
origin = default_origin;
|
||||
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
|
||||
origin = dev_origin;
|
||||
} else {
|
||||
origin = default_origin;
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
|
||||
var getPageCanonical = function() {
|
||||
var a = document.createElement('A'), link, href;
|
||||
if (document.querySelector) {
|
||||
link = document.querySelector('link[rel="canonical"]');
|
||||
if (link && (href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
} else {
|
||||
var links = document.getElementsByTagName('LINK');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if ((link = links[i]) &&
|
||||
(link.getAttribute('rel') == 'canonical') &&
|
||||
(href = link.getAttribute('href'))) {
|
||||
a.href = href;
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function haveTgAuthResult() {
|
||||
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
|
||||
try {
|
||||
locationHash = location.hash.toString();
|
||||
if (match = locationHash.match(re)) {
|
||||
location.hash = locationHash.replace(re, '');
|
||||
var data = match[1] || '';
|
||||
data = data.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var pad = data.length % 4;
|
||||
if (pad > 1) {
|
||||
data += new Array(5 - pad).join('=');
|
||||
}
|
||||
return JSON.parse(window.atob(data));
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getXHR() {
|
||||
if (navigator.appName == "Microsoft Internet Explorer"){
|
||||
return new ActiveXObject("Microsoft.XMLHTTP");
|
||||
} else {
|
||||
return new XMLHttpRequest();
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.Telegram) {
|
||||
window.Telegram = {};
|
||||
}
|
||||
if (!window.Telegram.__WidgetUuid) {
|
||||
window.Telegram.__WidgetUuid = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetLastId) {
|
||||
window.Telegram.__WidgetLastId = 0;
|
||||
}
|
||||
if (!window.Telegram.__WidgetCallbacks) {
|
||||
window.Telegram.__WidgetCallbacks = {};
|
||||
}
|
||||
|
||||
function postMessageToIframe(iframe, event, data, callback) {
|
||||
if (!iframe._ready) {
|
||||
if (!iframe._readyQueue) iframe._readyQueue = [];
|
||||
iframe._readyQueue.push([event, data, callback]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
data = data || {};
|
||||
data.event = event;
|
||||
if (callback) {
|
||||
data._cb = ++window.Telegram.__WidgetLastId;
|
||||
window.Telegram.__WidgetCallbacks[data._cb] = {
|
||||
iframe: iframe,
|
||||
callback: callback
|
||||
};
|
||||
}
|
||||
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function initWidget(widgetEl) {
|
||||
var widgetId, widgetElId, widgetsOrigin, existsEl,
|
||||
src, styles = {}, allowedAttrs = [],
|
||||
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
|
||||
if (!widgetEl.tagName ||
|
||||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
|
||||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
|
||||
widgetEl.classList.contains('telegram-post'))) {
|
||||
return null;
|
||||
}
|
||||
if (widgetEl._iframe) {
|
||||
return widgetEl._iframe;
|
||||
}
|
||||
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
|
||||
var comment = widgetEl.getAttribute('data-comment') || '';
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1';
|
||||
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = '';
|
||||
styles.minWidth = '320px';
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
|
||||
var websitePageUrl = widgetEl.getAttribute('data-page-url');
|
||||
if (!websitePageUrl) {
|
||||
websitePageUrl = getPageCanonical();
|
||||
}
|
||||
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
|
||||
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
|
||||
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||
defHeight = widgetEl.getAttribute('data-height') || 0;
|
||||
styles.minWidth = '320px';
|
||||
if (defHeight > 0) {
|
||||
scrollable = true;
|
||||
}
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-telegram-login')) {
|
||||
widgetId = widgetEl.getAttribute('data-telegram-login');
|
||||
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
|
||||
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
|
||||
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
|
||||
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
|
||||
defWidth = 186;
|
||||
defHeight = 28;
|
||||
if (widgetEl.hasAttribute('data-size')) {
|
||||
var size = widgetEl.getAttribute('data-size');
|
||||
if (size == 'small') defWidth = 148, defHeight = 20;
|
||||
else if (size == 'large') defWidth = 238, defHeight = 40;
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onauth')) {
|
||||
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
|
||||
}
|
||||
else if (widgetEl.hasAttribute('data-auth-url')) {
|
||||
var a = document.createElement('A');
|
||||
a.href = widgetEl.getAttribute('data-auth-url');
|
||||
onAuthUser = function(user) {
|
||||
var authUrl = a.href;
|
||||
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
|
||||
var params = [];
|
||||
for (var key in user) {
|
||||
params.push(key + '=' + encodeURIComponent(user[key]));
|
||||
}
|
||||
authUrl += params.join('&');
|
||||
location.href = authUrl;
|
||||
};
|
||||
}
|
||||
if (widgetEl.hasAttribute('data-onunauth')) {
|
||||
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
|
||||
}
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && onAuthUser) {
|
||||
onAuthUser(auth_result);
|
||||
}
|
||||
}
|
||||
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
|
||||
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||
widgetElId = 'telegram-share-' + window.btoa(widgetId);
|
||||
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
|
||||
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
|
||||
defWidth = 60;
|
||||
defHeight = 20;
|
||||
if (widgetEl.getAttribute('data-size') == 'large') {
|
||||
defWidth = 76;
|
||||
defHeight = 28;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
existsEl = document.getElementById(widgetElId);
|
||||
if (existsEl) {
|
||||
return existsEl;
|
||||
}
|
||||
for (var i = 0; i < allowedAttrs.length; i++) {
|
||||
var attr = allowedAttrs[i];
|
||||
var novalue = attr.substr(-1) == '?';
|
||||
if (novalue) {
|
||||
attr = attr.slice(0, -1);
|
||||
}
|
||||
var data_attr = 'data-' + attr.replace(/_/g, '-');
|
||||
if (widgetEl.hasAttribute(data_attr)) {
|
||||
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
|
||||
src += '&' + attr + '=' + attr_value;
|
||||
}
|
||||
}
|
||||
function getCurCoords(iframe) {
|
||||
var docEl = document.documentElement;
|
||||
var frect = iframe.getBoundingClientRect();
|
||||
return {
|
||||
frameTop: frect.top,
|
||||
frameBottom: frect.bottom,
|
||||
frameLeft: frect.left,
|
||||
frameRight: frect.right,
|
||||
frameWidth: frect.width,
|
||||
frameHeight: frect.height,
|
||||
scrollTop: window.pageYOffset,
|
||||
scrollLeft: window.pageXOffset,
|
||||
clientWidth: docEl.clientWidth,
|
||||
clientHeight: docEl.clientHeight
|
||||
};
|
||||
}
|
||||
function visibilityHandler() {
|
||||
if (isVisible(iframe, 50)) {
|
||||
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
|
||||
}
|
||||
}
|
||||
function focusHandler() {
|
||||
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
|
||||
}
|
||||
function postMessageHandler(event) {
|
||||
if (event.source !== iframe.contentWindow ||
|
||||
event.origin != widgetsOrigin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (data.event == 'resize') {
|
||||
if (data.height) {
|
||||
iframe.style.height = data.height + 'px';
|
||||
}
|
||||
if (data.width) {
|
||||
iframe.style.width = data.width + 'px';
|
||||
}
|
||||
}
|
||||
else if (data.event == 'ready') {
|
||||
iframe._ready = true;
|
||||
focusHandler();
|
||||
for (var i = 0; i < iframe._readyQueue.length; i++) {
|
||||
var queue_item = iframe._readyQueue[i];
|
||||
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
|
||||
}
|
||||
iframe._readyQueue = [];
|
||||
}
|
||||
else if (data.event == 'visible_off') {
|
||||
removeEvent(window, 'scroll', visibilityHandler);
|
||||
removeEvent(window, 'resize', visibilityHandler);
|
||||
}
|
||||
else if (data.event == 'get_coords') {
|
||||
postMessageToIframe(iframe, 'callback', {
|
||||
_cb: data._cb,
|
||||
value: getCurCoords(iframe)
|
||||
});
|
||||
}
|
||||
else if (data.event == 'scroll_to') {
|
||||
try {
|
||||
window.scrollTo(data.x || 0, data.y || 0);
|
||||
} catch(e) {}
|
||||
}
|
||||
else if (data.event == 'auth_user') {
|
||||
if (data.init) {
|
||||
onInitAuthUser && onInitAuthUser(data.auth_data);
|
||||
} else {
|
||||
onAuthUser && onAuthUser(data.auth_data);
|
||||
}
|
||||
}
|
||||
else if (data.event == 'unauthorized') {
|
||||
onUnauth && onUnauth();
|
||||
}
|
||||
else if (data.event == 'callback') {
|
||||
var cb_data = null;
|
||||
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
|
||||
if (cb_data.iframe === iframe) {
|
||||
cb_data.callback(data.value);
|
||||
delete window.Telegram.__WidgetCallbacks[data._cb];
|
||||
}
|
||||
} else {
|
||||
console.warn('Callback #' + data._cb + ' not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = widgetElId;
|
||||
iframe.src = src;
|
||||
iframe.width = defWidth;
|
||||
iframe.height = defHeight;
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
if (!scrollable) {
|
||||
iframe.setAttribute('scrolling', 'no');
|
||||
iframe.style.overflow = 'hidden';
|
||||
}
|
||||
iframe.style.colorScheme = 'light dark';
|
||||
iframe.style.border = 'none';
|
||||
for (var prop in styles) {
|
||||
iframe.style[prop] = styles[prop];
|
||||
}
|
||||
if (widgetEl.parentNode) {
|
||||
widgetEl.parentNode.insertBefore(iframe, widgetEl);
|
||||
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
|
||||
widgetEl.parentNode.removeChild(widgetEl);
|
||||
}
|
||||
}
|
||||
iframe._ready = false;
|
||||
iframe._readyQueue = [];
|
||||
widgetEl._iframe = iframe;
|
||||
addEvent(iframe, 'load', function() {
|
||||
removeEvent(iframe, 'load', visibilityHandler);
|
||||
addEvent(window, 'scroll', visibilityHandler);
|
||||
addEvent(window, 'resize', visibilityHandler);
|
||||
visibilityHandler();
|
||||
});
|
||||
addEvent(window, 'focus blur', focusHandler);
|
||||
addEvent(window, 'message', postMessageHandler);
|
||||
return iframe;
|
||||
}
|
||||
function isVisible(el, padding) {
|
||||
var node = el, val;
|
||||
var visibility = getCssProperty(node, 'visibility');
|
||||
if (visibility == 'hidden') return false;
|
||||
while (node) {
|
||||
if (node === document.documentElement) break;
|
||||
var display = getCssProperty(node, 'display');
|
||||
if (display == 'none') return false;
|
||||
var opacity = getCssProperty(node, 'opacity');
|
||||
if (opacity !== null && opacity < 0.1) return false;
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (el.getBoundingClientRect) {
|
||||
padding = +padding || 0;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var html = document.documentElement;
|
||||
if (rect.bottom < padding ||
|
||||
rect.right < padding ||
|
||||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
|
||||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllWidgets() {
|
||||
var widgets = [];
|
||||
if (document.querySelectorAll) {
|
||||
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
|
||||
} else {
|
||||
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
|
||||
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function getWidgetInfo(el_or_id, callback) {
|
||||
var e = null, iframe = null;
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe && callback) {
|
||||
postMessageToIframe(iframe, 'get_info', {}, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWidgetOptions(options, el_or_id) {
|
||||
var e = null, iframe = null;
|
||||
if (typeof el_or_id === 'undefined') {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
if (iframe = widgets[i]._iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (el = geById(el_or_id)) {
|
||||
if (el.tagName &&
|
||||
el.tagName.toUpperCase() == 'IFRAME') {
|
||||
iframe = el;
|
||||
} else if (el._iframe) {
|
||||
iframe = el._iframe;
|
||||
}
|
||||
if (iframe) {
|
||||
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.currentScript ||
|
||||
!initWidget(document.currentScript)) {
|
||||
var widgets = getAllWidgets();
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
initWidget(widgets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var TelegramLogin = {
|
||||
popups: {},
|
||||
options: null,
|
||||
auth_callback: null,
|
||||
_init: function(options, auth_callback) {
|
||||
TelegramLogin.options = options;
|
||||
TelegramLogin.auth_callback = auth_callback;
|
||||
var auth_result = haveTgAuthResult();
|
||||
if (auth_result && auth_callback) {
|
||||
auth_callback(auth_result);
|
||||
}
|
||||
},
|
||||
_open: function(callback) {
|
||||
TelegramLogin._auth(TelegramLogin.options, function(authData) {
|
||||
if (TelegramLogin.auth_callback) {
|
||||
TelegramLogin.auth_callback(authData);
|
||||
}
|
||||
if (callback) {
|
||||
callback(authData);
|
||||
}
|
||||
});
|
||||
},
|
||||
_auth: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var width = 550;
|
||||
var height = 470;
|
||||
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
|
||||
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
|
||||
var onMessage = function (event) {
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
} catch(e) {
|
||||
var data = {};
|
||||
}
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (event.source !== TelegramLogin.popups[bot_id].window) return;
|
||||
if (data.event == 'auth_result') {
|
||||
onAuthDone(data.result);
|
||||
}
|
||||
};
|
||||
var onAuthDone = function (authData) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (TelegramLogin.popups[bot_id].authFinished) return;
|
||||
callback && callback(authData);
|
||||
TelegramLogin.popups[bot_id].authFinished = true;
|
||||
removeEvent(window, 'message', onMessage);
|
||||
};
|
||||
var checkClose = function(bot_id) {
|
||||
if (!TelegramLogin.popups[bot_id]) return;
|
||||
if (!TelegramLogin.popups[bot_id].window ||
|
||||
TelegramLogin.popups[bot_id].window.closed) {
|
||||
return TelegramLogin.getAuthData(options, function(origin, authData) {
|
||||
onAuthDone(authData);
|
||||
});
|
||||
}
|
||||
setTimeout(checkClose, 100, bot_id);
|
||||
}
|
||||
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
|
||||
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
|
||||
TelegramLogin.popups[bot_id] = {
|
||||
window: popup,
|
||||
authFinished: false
|
||||
};
|
||||
if (popup) {
|
||||
addEvent(window, 'message', onMessage);
|
||||
popup.focus();
|
||||
checkClose(bot_id);
|
||||
}
|
||||
},
|
||||
getAuthData: function(options, callback) {
|
||||
var bot_id = parseInt(options.bot_id);
|
||||
if (!bot_id) {
|
||||
throw new Error('Bot id required');
|
||||
}
|
||||
var xhr = getXHR();
|
||||
var url = Telegram.Login.widgetsOrigin + '/auth/get';
|
||||
xhr.open('POST', url);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState == 4) {
|
||||
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
|
||||
try {
|
||||
var result = JSON.parse(xhr.responseText);
|
||||
} catch(e) {
|
||||
var result = {};
|
||||
}
|
||||
if (result.user) {
|
||||
callback(result.origin, result.user);
|
||||
} else {
|
||||
callback(result.origin, false);
|
||||
}
|
||||
} else {
|
||||
callback('*', false);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
callback('*', false);
|
||||
};
|
||||
xhr.withCredentials = true;
|
||||
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
|
||||
}
|
||||
};
|
||||
|
||||
window.Telegram.getWidgetInfo = getWidgetInfo;
|
||||
window.Telegram.setWidgetOptions = setWidgetOptions;
|
||||
window.Telegram.Login = {
|
||||
init: TelegramLogin._init,
|
||||
open: TelegramLogin._open,
|
||||
auth: TelegramLogin._auth,
|
||||
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
|
||||
};
|
||||
|
||||
}(window));
|
||||
})(window);
|
||||
@@ -1,7 +1,6 @@
|
||||
import hmac
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
@@ -17,8 +16,6 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
return False
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
@@ -132,13 +129,8 @@ async def build_and_start_web_app(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = None
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import (
|
||||
create_subscription_webapp_application,
|
||||
refresh_telegram_login_widget_sdk,
|
||||
refresh_telegram_web_app_sdk,
|
||||
)
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
|
||||
subscription_app = create_subscription_webapp_application(
|
||||
dp,
|
||||
@@ -161,25 +153,9 @@ async def build_and_start_web_app(
|
||||
settings.WEBAPP_SERVER_PORT,
|
||||
)
|
||||
|
||||
async def _refresh_telegram_web_assets_forever() -> None:
|
||||
while True:
|
||||
await refresh_telegram_web_app_sdk()
|
||||
await refresh_telegram_login_widget_sdk()
|
||||
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
|
||||
|
||||
telegram_web_app_sdk_refresh_task = asyncio.create_task(
|
||||
_refresh_telegram_web_assets_forever(),
|
||||
name="TelegramWebAssetsRefreshTask",
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
if telegram_web_app_sdk_refresh_task is not None:
|
||||
telegram_web_app_sdk_refresh_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await telegram_web_app_sdk_refresh_task
|
||||
|
||||
for runner in reversed(runners):
|
||||
try:
|
||||
await runner.cleanup()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
@@ -13,6 +15,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 5 minutes clock skew tolerance for Telegram clients
|
||||
TELEGRAM_CLOCK_SKEW_SECONDS = 300
|
||||
TELEGRAM_OAUTH_ISSUER = "https://oauth.telegram.org"
|
||||
TELEGRAM_OAUTH_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
|
||||
TELEGRAM_OAUTH_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
|
||||
|
||||
|
||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||
@@ -74,6 +79,181 @@ def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]
|
||||
return None
|
||||
|
||||
|
||||
def create_telegram_oauth_nonce(settings: Settings, *, ttl_seconds: int = 600) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"n": secrets.token_urlsafe(24),
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_telegram_oauth_nonce(settings: Settings, nonce: str) -> bool:
|
||||
if not nonce or "." not in nonce:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload_part, signature_part = nonce.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-nonce.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return False
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return False
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return False
|
||||
return bool(payload.get("n"))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth nonce: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def create_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
ttl_seconds: int = 600,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
state_payload = {
|
||||
**payload,
|
||||
"iat": now,
|
||||
"exp": now + max(60, int(ttl_seconds)),
|
||||
}
|
||||
payload_part = _urlsafe_b64encode(
|
||||
json.dumps(state_payload, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
|
||||
|
||||
|
||||
def verify_signed_telegram_oauth_state(
|
||||
settings: Settings,
|
||||
state: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not state or "." not in state:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload_part, signature_part = state.split(".", 1)
|
||||
expected_signature = hmac.new(
|
||||
_session_secret(settings),
|
||||
f"telegram-oauth-state.{payload_part}".encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
received_signature = _urlsafe_b64decode(signature_part)
|
||||
if not hmac.compare_digest(expected_signature, received_signature):
|
||||
return None
|
||||
|
||||
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
|
||||
now = int(time.time())
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
return None
|
||||
if int(payload.get("iat", 0)) > now + TELEGRAM_CLOCK_SKEW_SECONDS:
|
||||
return None
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to verify Telegram OAuth state: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def validate_telegram_oauth_id_token(
|
||||
id_token: str,
|
||||
*,
|
||||
client_id: int,
|
||||
expected_nonce: str,
|
||||
max_age_seconds: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate Telegram OIDC ID token and return a Telegram-like user payload."""
|
||||
|
||||
if not id_token or not client_id or not expected_nonce:
|
||||
return None
|
||||
|
||||
try:
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"PyJWT is not installed; Telegram OAuth ID token validation is unavailable: %s",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
jwks_client = PyJWKClient(TELEGRAM_OAUTH_JWKS_URL)
|
||||
signing_key = await asyncio.to_thread(
|
||||
jwks_client.get_signing_key_from_jwt,
|
||||
id_token,
|
||||
)
|
||||
claims = await asyncio.to_thread(
|
||||
jwt.decode,
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=TELEGRAM_OAUTH_ALGORITHMS,
|
||||
audience=str(client_id),
|
||||
issuer=TELEGRAM_OAUTH_ISSUER,
|
||||
leeway=TELEGRAM_CLOCK_SKEW_SECONDS,
|
||||
options={"require": ["exp", "iat", "iss", "aud"]},
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(str(claims.get("nonce") or ""), expected_nonce):
|
||||
logger.warning("Telegram OAuth nonce mismatch.")
|
||||
return None
|
||||
|
||||
now = int(time.time())
|
||||
issued_at = int(claims.get("iat") or 0)
|
||||
max_age = max(60, int(max_age_seconds))
|
||||
if issued_at > now + TELEGRAM_CLOCK_SKEW_SECONDS or now - issued_at > max_age:
|
||||
logger.warning("Telegram OAuth ID token is stale.")
|
||||
return None
|
||||
|
||||
telegram_id_raw = claims.get("id")
|
||||
if not telegram_id_raw:
|
||||
return None
|
||||
telegram_id = int(telegram_id_raw)
|
||||
|
||||
full_name = str(claims.get("name") or "").strip()
|
||||
first_name = str(claims.get("given_name") or "").strip()
|
||||
last_name = str(claims.get("family_name") or "").strip()
|
||||
if full_name and not first_name:
|
||||
name_parts = full_name.split(None, 1)
|
||||
first_name = name_parts[0]
|
||||
if len(name_parts) > 1 and not last_name:
|
||||
last_name = name_parts[1]
|
||||
|
||||
return {
|
||||
"id": telegram_id,
|
||||
"username": claims.get("preferred_username") or claims.get("username"),
|
||||
"first_name": first_name or full_name or "Telegram",
|
||||
"last_name": last_name,
|
||||
"photo_url": claims.get("picture"),
|
||||
"language_code": claims.get("locale"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram OAuth ID token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def validate_telegram_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
|
||||
@@ -31,7 +31,8 @@ MAX_PROMO_CODE_INPUT_LENGTH = 100
|
||||
|
||||
async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
settings: Settings, session: AsyncSession,
|
||||
back_callback: str = "main_action:back_to_main"):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -52,14 +53,22 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
current_lang,
|
||||
i18n,
|
||||
callback_data=back_callback,
|
||||
))
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
current_lang,
|
||||
i18n,
|
||||
callback_data=back_callback,
|
||||
))
|
||||
|
||||
await safe_answer_callback(callback)
|
||||
await state.set_state(UserPromoStates.waiting_for_promo_code)
|
||||
|
||||
@@ -9,17 +9,16 @@ from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from bot.services.referral_service import ReferralService
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="user_referral_router")
|
||||
|
||||
|
||||
async def referral_command_handler(event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
async def referral_command_handler(event: Union[types.Message, types.CallbackQuery],
|
||||
settings: Settings, i18n_data: dict,
|
||||
referral_service: ReferralService, bot: Bot,
|
||||
session: AsyncSession):
|
||||
session: AsyncSession,
|
||||
back_callback: str = "main_action:back_to_main"):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
@@ -97,27 +96,35 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
# Get referral statistics
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
|
||||
webapp_referral_link = await _generate_webapp_referral_link(
|
||||
session,
|
||||
settings,
|
||||
inviter_user_id,
|
||||
)
|
||||
webapp_link_section = (
|
||||
_(
|
||||
"referral_webapp_link_line",
|
||||
webapp_referral_link=webapp_referral_link,
|
||||
)
|
||||
if webapp_referral_link
|
||||
else ""
|
||||
)
|
||||
|
||||
text = _("referral_program_info_new",
|
||||
referral_link=referral_link,
|
||||
webapp_link_section=webapp_link_section,
|
||||
bonus_details=bonus_details_str,
|
||||
invited_count=referral_stats["invited_count"],
|
||||
purchased_count=referral_stats["purchased_count"])
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
if webapp_referral_link:
|
||||
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
|
||||
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
|
||||
reply_markup_val = get_referral_link_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
)
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(text,
|
||||
@@ -152,7 +159,7 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
if not bot_username:
|
||||
await callback.answer("Ошибка получения имени бота", show_alert=True)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
@@ -166,18 +173,30 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
|
||||
webapp_referral_link = await _generate_webapp_referral_link(
|
||||
session,
|
||||
settings,
|
||||
inviter_user_id,
|
||||
)
|
||||
if webapp_referral_link:
|
||||
friend_message = _(
|
||||
"referral_friend_message_with_webapp",
|
||||
referral_link=referral_link,
|
||||
webapp_referral_link=webapp_referral_link,
|
||||
)
|
||||
else:
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
await callback.message.answer(
|
||||
friend_message,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in referral share message: {e}")
|
||||
await callback.answer("Произошла ошибка", show_alert=True)
|
||||
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -196,3 +215,25 @@ def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _generate_webapp_referral_link(
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
inviter_user_id: int,
|
||||
) -> Optional[str]:
|
||||
if not settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
return None
|
||||
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
|
||||
return _build_webapp_referral_link(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("referral"))
|
||||
async def referral_command_message_handler(message: types.Message, settings: Settings,
|
||||
i18n_data: dict, referral_service: ReferralService,
|
||||
bot: Bot, session: AsyncSession):
|
||||
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
|
||||
|
||||
@@ -152,6 +152,11 @@ async def send_bot_interface_menu(
|
||||
settings, subscription_service, session, user_id)
|
||||
|
||||
text = i18n.gettext(current_lang, "bot_interface_menu_title")
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
text = (
|
||||
f"{text}\n\n"
|
||||
f"{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
|
||||
)
|
||||
reply_markup = get_bot_interface_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_in_menu)
|
||||
|
||||
@@ -797,6 +802,7 @@ async def language_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -804,7 +810,11 @@ async def language_command_handler(
|
||||
) if i18n else key
|
||||
|
||||
text_to_send = _(key="choose_language")
|
||||
reply_markup = get_language_selection_keyboard(i18n, current_lang)
|
||||
reply_markup = get_language_selection_keyboard(
|
||||
i18n,
|
||||
current_lang,
|
||||
back_callback=back_callback,
|
||||
)
|
||||
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
@@ -845,7 +855,8 @@ async def select_language_callback_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
lang_code = callback.data.split("_")[2]
|
||||
lang_payload = callback.data.split("_", 2)[2]
|
||||
lang_code, _, return_target = lang_payload.partition(":")
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
@@ -879,12 +890,20 @@ async def select_language_callback_handler(
|
||||
exc_info=True)
|
||||
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
|
||||
return
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
if return_target == "bot":
|
||||
await send_bot_interface_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
else:
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("main_action:"))
|
||||
@@ -896,6 +915,9 @@ async def main_action_callback_handler(
|
||||
action = callback.data.split(":")[1]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
if action in {"back_to_main", "back_to_main_keep", "bot_interface"}:
|
||||
await state.clear()
|
||||
|
||||
from . import subscription as user_subscription_handlers
|
||||
from . import referral as user_referral_handlers
|
||||
from . import promo_user as user_promo_handlers
|
||||
@@ -912,10 +934,29 @@ async def main_action_callback_handler(
|
||||
if action == "subscribe":
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "bot_subscribe":
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
session,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
elif action == "my_subscription":
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "bot_my_subscription":
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
session,
|
||||
bot,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
elif action == "my_devices":
|
||||
await user_subscription_handlers.my_devices_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
@@ -923,16 +964,49 @@ async def main_action_callback_handler(
|
||||
elif action == "referral":
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback, settings, i18n_data, referral_service, bot, session)
|
||||
elif action == "bot_referral":
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
referral_service,
|
||||
bot,
|
||||
session,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
elif action == "apply_promo":
|
||||
await user_promo_handlers.prompt_promo_code_input(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "bot_apply_promo":
|
||||
await user_promo_handlers.prompt_promo_code_input(
|
||||
callback,
|
||||
state,
|
||||
i18n_data,
|
||||
settings,
|
||||
session,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
elif action == "request_trial":
|
||||
await user_trial_handlers.request_trial_confirmation_handler(
|
||||
callback, settings, i18n_data, subscription_service, session)
|
||||
elif action == "language":
|
||||
|
||||
await language_command_handler(callback, i18n_data, settings)
|
||||
elif action == "info":
|
||||
elif action == "bot_language":
|
||||
await language_command_handler(
|
||||
callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
elif action == "bot_interface":
|
||||
await send_bot_interface_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
elif action in {"info", "bot_info"}:
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language",
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
@@ -962,6 +1036,11 @@ async def main_action_callback_handler(
|
||||
i18n,
|
||||
privacy_url,
|
||||
user_agreement_url,
|
||||
back_callback=(
|
||||
"main_action:bot_interface"
|
||||
if callback.data == "main_action:bot_info"
|
||||
else "main_action:back_to_main"
|
||||
),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(_(key="info_links_message"),
|
||||
|
||||
@@ -39,7 +39,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
|
||||
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
@@ -75,11 +81,20 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
|
||||
if options:
|
||||
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
|
||||
options,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
traffic_mode=traffic_mode,
|
||||
back_callback=back_callback,
|
||||
)
|
||||
else:
|
||||
text_content = get_text("no_subscription_options_available")
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang,
|
||||
i18n,
|
||||
callback_data=back_callback,
|
||||
)
|
||||
|
||||
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
@@ -116,6 +131,7 @@ async def my_subscription_command_handler(
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
back_callback: str = "main_action:back_to_main",
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -139,7 +155,11 @@ async def my_subscription_command_handler(
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
back_markup = get_back_to_main_menu_markup(
|
||||
current_lang,
|
||||
i18n,
|
||||
callback_data=back_callback,
|
||||
)
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
|
||||
|
||||
@@ -231,7 +251,11 @@ async def my_subscription_command_handler(
|
||||
traffic_period=period_label,
|
||||
)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
base_markup = get_back_to_main_menu_markup(
|
||||
current_lang,
|
||||
i18n,
|
||||
callback_data=back_callback,
|
||||
)
|
||||
kb = base_markup.inline_keyboard
|
||||
try:
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||
|
||||
@@ -28,11 +28,21 @@ def get_main_menu_inline_keyboard(
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_bot_interface_button"),
|
||||
callback_data="main_action:bot_interface"))
|
||||
|
||||
if settings.SUPPORT_LINK:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"),
|
||||
url=settings.SUPPORT_LINK))
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||
callback_data="main_action:info"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -59,26 +69,26 @@ def get_bot_interface_inline_keyboard(
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
callback_data="main_action:bot_subscribe"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
callback_data="main_action:bot_my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
callback_data="main_action:referral")
|
||||
callback_data="main_action:bot_referral")
|
||||
promo_button = InlineKeyboardButton(
|
||||
text=_(key="menu_apply_promo_button"),
|
||||
callback_data="main_action:apply_promo")
|
||||
callback_data="main_action:bot_apply_promo")
|
||||
builder.row(referral_button)
|
||||
builder.row(promo_button)
|
||||
|
||||
language_button = InlineKeyboardButton(
|
||||
text=_(key="menu_language_settings_inline"),
|
||||
callback_data="main_action:language")
|
||||
callback_data="main_action:bot_language")
|
||||
status_button_list = []
|
||||
if settings.SERVER_STATUS_URL:
|
||||
status_button_list.append(
|
||||
@@ -99,7 +109,11 @@ def get_bot_interface_inline_keyboard(
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"),
|
||||
callback_data="main_action:info"))
|
||||
callback_data="main_action:bot_info"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -108,7 +122,8 @@ def get_information_links_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
privacy_policy_url: Optional[str],
|
||||
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
|
||||
user_agreement_url: Optional[str],
|
||||
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if privacy_policy_url:
|
||||
@@ -121,21 +136,23 @@ def get_information_links_keyboard(
|
||||
url=user_agreement_url))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main"))
|
||||
callback_data=back_callback))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_language_selection_keyboard(i18n_instance,
|
||||
current_lang: str) -> InlineKeyboardMarkup:
|
||||
current_lang: str,
|
||||
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
|
||||
)
|
||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
||||
callback_data="set_lang_en")
|
||||
callback_data=f"set_lang_en{callback_suffix}")
|
||||
builder.button(text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
||||
callback_data="set_lang_ru")
|
||||
callback_data=f"set_lang_ru{callback_suffix}")
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -154,7 +171,8 @@ def get_trial_confirmation_keyboard(lang: str,
|
||||
|
||||
def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
float, Optional[float]], currency_symbol_val: str, lang: str,
|
||||
i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup:
|
||||
i18n_instance, traffic_mode: bool = False,
|
||||
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
def _format_gb(val: float) -> str:
|
||||
@@ -181,7 +199,7 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
builder.adjust(1)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main"))
|
||||
callback_data=back_callback))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -365,13 +383,14 @@ def get_yk_saved_cards_keyboard(
|
||||
|
||||
|
||||
def get_referral_link_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
i18n_instance,
|
||||
back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="referral_share_message_button"),
|
||||
callback_data="referral_action:share_message")
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.models import EmailVerificationCode
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,15 @@ class EmailCodeVerifyResult:
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailMagicVerifyResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
target_user_id: Optional[int] = None
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
@@ -97,6 +107,31 @@ class EmailAuthService:
|
||||
payload = f"{purpose}:{email}:{code}".encode("utf-8")
|
||||
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
def _hash_magic_token(self, token: str) -> str:
|
||||
secret = hmac.new(
|
||||
self.settings.BOT_TOKEN.encode("utf-8"),
|
||||
b"remnawave-tg-shop-email-magic",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return hmac.new(secret, token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _build_magic_link(self, *, token: str, purpose: str) -> Optional[str]:
|
||||
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return None
|
||||
params = {"login_token": token}
|
||||
if purpose and purpose != "login":
|
||||
params["login_purpose"] = purpose
|
||||
existing_query = parsed.query
|
||||
new_query = urlencode(params)
|
||||
merged_query = f"{existing_query}&{new_query}" if existing_query else new_query
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, merged_query, parsed.fragment))
|
||||
|
||||
async def request_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -158,9 +193,12 @@ class EmailAuthService:
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
magic_token = secrets.token_urlsafe(32)
|
||||
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
|
||||
code_model = EmailVerificationCode(
|
||||
email=normalized_email,
|
||||
code_hash=self._hash_code(normalized_email, purpose, code),
|
||||
magic_token_hash=self._hash_magic_token(magic_token) if magic_link else None,
|
||||
purpose=purpose,
|
||||
target_user_id=target_user_id,
|
||||
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||
@@ -173,6 +211,7 @@ class EmailAuthService:
|
||||
email=normalized_email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
return EmailCodeRequestResult(ok=True)
|
||||
|
||||
@@ -303,18 +342,74 @@ class EmailAuthService:
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def verify_magic_token(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
purpose: str,
|
||||
target_user_id: Optional[int] = None,
|
||||
) -> EmailMagicVerifyResult:
|
||||
if not token:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
token_hash = self._hash_magic_token(token)
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.magic_token_hash == token_hash,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.target_user_id == target_user_id,
|
||||
EmailVerificationCode.status == "active",
|
||||
EmailVerificationCode.consumed_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return EmailMagicVerifyResult(ok=False, error="invalid_token")
|
||||
|
||||
expires_at = record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < now:
|
||||
return EmailMagicVerifyResult(ok=False, error="expired_token")
|
||||
|
||||
record.consumed_at = now
|
||||
throttle_identifier = _email_throttle_identifier(
|
||||
record.email,
|
||||
purpose,
|
||||
target_user_id,
|
||||
)
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
await session.flush()
|
||||
return EmailMagicVerifyResult(
|
||||
ok=True,
|
||||
email=record.email,
|
||||
purpose=record.purpose,
|
||||
target_user_id=record.target_user_id,
|
||||
)
|
||||
|
||||
async def _send_code_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_code_email_sync,
|
||||
email=email,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
async def send_custom_email(
|
||||
@@ -323,12 +418,27 @@ class EmailAuthService:
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
)
|
||||
|
||||
async def send_rendered_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
content: EmailContent,
|
||||
) -> None:
|
||||
await self.send_custom_email(
|
||||
email=email,
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
@@ -337,23 +447,17 @@ class EmailAuthService:
|
||||
email: str,
|
||||
code: str,
|
||||
language_code: str,
|
||||
magic_link: Optional[str] = None,
|
||||
) -> None:
|
||||
lang = (language_code or self.settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
if lang == "en":
|
||||
subject = "Your login code"
|
||||
body = (
|
||||
f"Your verification code: {code}\n\n"
|
||||
f"The code expires in {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} minutes."
|
||||
)
|
||||
else:
|
||||
subject = "Код подтверждения"
|
||||
body = (
|
||||
f"Ваш код подтверждения: {code}\n\n"
|
||||
f"Код действует {max(1, int(self.settings.EMAIL_CODE_TTL_SECONDS) // 60)} мин."
|
||||
)
|
||||
content = render_login_code(
|
||||
self.settings,
|
||||
code=code,
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["Subject"] = content.subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
@@ -361,7 +465,8 @@ class EmailAuthService:
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
message.set_content(content.text)
|
||||
message.add_alternative(content.html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -411,6 +516,7 @@ class EmailAuthService:
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
@@ -422,6 +528,8 @@ class EmailAuthService:
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Branded HTML email templates that mirror the subscription Mini App look.
|
||||
|
||||
The web app uses a dark theme with a configurable accent colour
|
||||
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
|
||||
accent + logo are reused here so emails feel like part of the product. All
|
||||
copy goes through the shared `JsonI18n` instance so translations live in
|
||||
``locales/<lang>.json`` next to the rest of the bot strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
_BG = "#05070a"
|
||||
_CARD_BG = "#0e1116"
|
||||
_BORDER = "#1a1f27"
|
||||
_TEXT = "#e6e9ef"
|
||||
_TEXT_MUTED = "#9aa3b2"
|
||||
_TEXT_DIM = "#5d6573"
|
||||
_DEFAULT_ACCENT = "#00fe7a"
|
||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailContent:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
|
||||
|
||||
def _safe_color(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return _DEFAULT_ACCENT
|
||||
candidate = value.strip()
|
||||
if _HEX_RE.match(candidate):
|
||||
return candidate
|
||||
return _DEFAULT_ACCENT
|
||||
|
||||
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
|
||||
raw https URL from the env is used directly. Anything else is dropped."""
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _brand_title(settings: Settings) -> str:
|
||||
title = (settings.WEBAPP_TITLE or "").strip()
|
||||
return title or "Subscription"
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
return i18n or get_i18n_instance()
|
||||
|
||||
|
||||
def _t_html(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
"""Translate for HTML context: format args are HTML-escaped, the
|
||||
translated template itself is treated as already-safe HTML (locale files
|
||||
are author-controlled and may include simple inline tags like <strong>)."""
|
||||
safe_kwargs = {k: html.escape(str(v)) for k, v in kwargs.items()}
|
||||
return i18n.gettext(lang, key, **safe_kwargs)
|
||||
|
||||
|
||||
def _t_text(i18n: JsonI18n, lang: str, key: str, **kwargs) -> str:
|
||||
return i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
|
||||
def _layout(
|
||||
*,
|
||||
settings: Settings,
|
||||
preheader: str,
|
||||
heading: str,
|
||||
intro_html: str,
|
||||
body_html: str,
|
||||
footer_html: str,
|
||||
) -> str:
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
logo_block = (
|
||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||
f'border-radius:16px;">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{html.escape(heading)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:{_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:{_TEXT};">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">{html.escape(preheader)}</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{_BG};">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
{logo_block}
|
||||
<div style="margin-top:14px;font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-weight:800;font-size:22px;line-height:1.05;color:{accent};letter-spacing:0;">{brand_title}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:{_CARD_BG};border:1px solid {_BORDER};border-radius:18px;padding:28px;">
|
||||
<h1 style="margin:0 0 10px 0;font-size:20px;line-height:1.25;font-weight:700;color:#ffffff;">{html.escape(heading)}</h1>
|
||||
<div style="margin:0 0 20px 0;font-size:14px;line-height:1.55;color:{_TEXT_MUTED};">{intro_html}</div>
|
||||
{body_html}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:20px;">
|
||||
<div style="font-size:11px;line-height:1.55;color:{_TEXT_DIM};">{footer_html}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||
if not rows:
|
||||
return ""
|
||||
last = len(rows) - 1
|
||||
cells = []
|
||||
for index, (label, value) in enumerate(rows):
|
||||
border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
|
||||
cells.append(
|
||||
f'<tr>'
|
||||
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>'
|
||||
f'<td align="right" style="padding:11px 0;{border}font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};">{html.escape(value)}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">'
|
||||
+ "".join(cells)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def _cta_button_html(*, label: str, url: str, accent: str) -> str:
|
||||
safe_label = html.escape(label)
|
||||
safe_url = html.escape(url, quote=True)
|
||||
# Accent green is light, so contrast text is dark; works for the default and similar light accents.
|
||||
return (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||||
f'style="width:100%;margin:22px 0 18px 0;">'
|
||||
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">'
|
||||
f'<a href="{safe_url}" target="_blank" rel="noopener" '
|
||||
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
|
||||
f'font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;'
|
||||
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
|
||||
f'</td></tr></table>'
|
||||
)
|
||||
|
||||
|
||||
def _format_amount(amount: float, currency: str) -> str:
|
||||
rounded = round(float(amount), 2)
|
||||
if rounded.is_integer():
|
||||
body = f"{int(rounded)}"
|
||||
else:
|
||||
body = f"{rounded:.2f}"
|
||||
suffix = (currency or "").strip()
|
||||
return f"{body} {suffix}".strip()
|
||||
|
||||
|
||||
def _format_traffic(traffic_gb: Optional[float]) -> str:
|
||||
if traffic_gb is None:
|
||||
return "—"
|
||||
value = float(traffic_gb)
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
def _format_minutes(seconds: int) -> int:
|
||||
return max(1, int(seconds) // 60)
|
||||
|
||||
|
||||
def render_login_code(
|
||||
settings: Settings,
|
||||
*,
|
||||
code: str,
|
||||
language_code: Optional[str],
|
||||
magic_link: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_magic_link = (magic_link or "").strip()
|
||||
|
||||
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
|
||||
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
|
||||
heading = _t_text(i18n, lang, "email_login_code_heading")
|
||||
intro = _t_text(i18n, lang, "email_login_code_intro")
|
||||
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
|
||||
security = _t_text(i18n, lang, "email_login_code_security")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
|
||||
if safe_magic_link:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)
|
||||
)
|
||||
|
||||
code_block = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">'
|
||||
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">'
|
||||
f'<div style="font-family:\'JetBrains Mono\',\'SFMono-Regular\',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};">'
|
||||
f'{html.escape(code)}'
|
||||
f'</div></td></tr></table>'
|
||||
)
|
||||
|
||||
magic_block = ""
|
||||
if safe_magic_link:
|
||||
cta_label = _t_text(i18n, lang, "email_login_code_magic_cta")
|
||||
divider_label = _t_text(i18n, lang, "email_login_code_magic_or")
|
||||
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
|
||||
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
|
||||
divider_html = (
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">'
|
||||
f'<tr>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>'
|
||||
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </td>'
|
||||
f'</tr></table>'
|
||||
)
|
||||
magic_block = (
|
||||
divider_html
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>'
|
||||
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
|
||||
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>'
|
||||
)
|
||||
|
||||
body_html = (
|
||||
code_block
|
||||
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>'
|
||||
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>'
|
||||
+ magic_block
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
primary_user_id: Optional[int],
|
||||
removed_user_id: Optional[int],
|
||||
final_end_date_text: str,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
brand = _brand_title(settings)
|
||||
primary = "—" if primary_user_id is None else f"#{primary_user_id}"
|
||||
removed = "—" if removed_user_id is None else f"#{removed_user_id}"
|
||||
end_date = final_end_date_text or "—"
|
||||
|
||||
subject = _t_text(i18n, lang, "email_account_merged_subject")
|
||||
preheader = _t_text(i18n, lang, "email_account_merged_preheader")
|
||||
heading = _t_text(i18n, lang, "email_account_merged_heading")
|
||||
intro = _t_text(i18n, lang, "email_account_merged_intro")
|
||||
note = _t_text(i18n, lang, "email_account_merged_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_account_merged_text",
|
||||
primary=primary,
|
||||
removed=removed,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_account_merged_row_kept"), primary),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_removed"), removed),
|
||||
(_t_text(i18n, lang, "email_account_merged_row_end_date"), end_date),
|
||||
]
|
||||
body_html = (
|
||||
_info_rows_html(rows)
|
||||
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
|
||||
|
||||
def render_payment_success(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
amount: float,
|
||||
currency: str,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
provider_label: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
is_traffic = sale_mode == "traffic"
|
||||
amount_text = _format_amount(amount, currency)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
traffic_label = _format_traffic(traffic_gb)
|
||||
|
||||
subject = _t_text(i18n, lang, "email_payment_success_subject")
|
||||
preheader = _t_text(i18n, lang, "email_payment_success_preheader")
|
||||
heading = _t_text(i18n, lang, "email_payment_success_heading")
|
||||
footer_note = _t_text(i18n, lang, "email_payment_success_footer_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||
|
||||
if is_traffic:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_traffic",
|
||||
amount=amount_text,
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date,
|
||||
)
|
||||
else:
|
||||
months_int = int(months or 0)
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_period")
|
||||
period_value = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_period_value",
|
||||
months=months_int,
|
||||
)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_subscription",
|
||||
amount=amount_text,
|
||||
months=months_int,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
rows: list[Tuple[str, str]] = [
|
||||
(period_label, period_value),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_amount"), amount_text),
|
||||
(_t_text(i18n, lang, "email_payment_success_row_end_date"), end_date),
|
||||
]
|
||||
if provider_label:
|
||||
rows.append((_t_text(i18n, lang, "email_payment_success_row_method"), provider_label))
|
||||
|
||||
text_lines = [text]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(_t_text(i18n, lang, "email_payment_success_text_dashboard", url=safe_dashboard_url))
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
days = max(0, int(days_left))
|
||||
end_date = end_date_text or "—"
|
||||
|
||||
if days == 0:
|
||||
suffix = "today"
|
||||
elif days == 1:
|
||||
suffix = "tomorrow"
|
||||
else:
|
||||
suffix = "days"
|
||||
|
||||
subject = _t_text(i18n, lang, f"email_subscription_expiring_subject_{suffix}", days=days)
|
||||
heading = _t_text(i18n, lang, f"email_subscription_expiring_heading_{suffix}", days=days)
|
||||
preheader = _t_text(i18n, lang, f"email_subscription_expiring_preheader_{suffix}", days=days)
|
||||
intro = _t_text(i18n, lang, f"email_subscription_expiring_intro_{suffix}", days=days)
|
||||
note = _t_text(i18n, lang, "email_subscription_expiring_note")
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_subscription_expiring_cta")
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_days_left"), str(days)),
|
||||
(_t_text(i18n, lang, "email_subscription_expiring_row_end_date"), end_date),
|
||||
]
|
||||
|
||||
text_lines = [
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text", heading=heading, end_date=end_date),
|
||||
]
|
||||
if safe_dashboard_url:
|
||||
text_lines.append(
|
||||
_t_text(i18n, lang, "email_subscription_expiring_text_renew", url=safe_dashboard_url)
|
||||
)
|
||||
|
||||
body_parts = [_info_rows_html(rows)]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
body_parts.append(
|
||||
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>'
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=preheader,
|
||||
heading=heading,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
@@ -212,7 +212,118 @@ class NotificationService:
|
||||
# Send to log channel
|
||||
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
|
||||
async def notify_new_email_user_registration(
|
||||
self,
|
||||
user_id: int,
|
||||
email: str,
|
||||
referred_by_id: Optional[int] = None,
|
||||
):
|
||||
"""Send notification about new user registration via email (Web App)."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||
referral_text = _(
|
||||
"log_referral_suffix",
|
||||
referrer_link=referrer_link,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_new_email_user_registration",
|
||||
user_id=user_id,
|
||||
email=hd.quote(email),
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
# Email users have a synthetic (negative) user_id with no Telegram profile,
|
||||
# so we only attach the referrer button when a real referrer is present.
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||
if referred_by_id and referred_by_id > 0:
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=[[
|
||||
InlineKeyboardButton(
|
||||
text=_("log_open_referrer_profile_button"),
|
||||
url=f"tg://user?id={referred_by_id}",
|
||||
)
|
||||
]])
|
||||
|
||||
await self._send_to_log_channel(message, reply_markup=reply_markup)
|
||||
|
||||
async def notify_account_email_linked(
|
||||
self,
|
||||
user_id: int,
|
||||
email: str,
|
||||
telegram_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when an email is linked to a Telegram-created account."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=telegram_id or user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_email_linked",
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id or user_id,
|
||||
user_display=user_display,
|
||||
email=hd.quote(email),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||
if telegram_id and telegram_id > 0:
|
||||
reply_markup = self._build_profile_keyboard(_, telegram_id)
|
||||
|
||||
await self._send_to_log_channel(message, reply_markup=reply_markup)
|
||||
|
||||
async def notify_account_telegram_linked(
|
||||
self,
|
||||
user_id: int,
|
||||
email: Optional[str],
|
||||
telegram_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when Telegram is linked to an email-created account."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = self._format_user_display(
|
||||
user_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_telegram_linked",
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id,
|
||||
user_display=user_display,
|
||||
email=hd.quote(email or ""),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None,
|
||||
|
||||
@@ -9,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
|
||||
from typing import Optional
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
|
||||
from db.dal import user_dal
|
||||
@@ -64,6 +66,7 @@ class PanelWebhookService:
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
@@ -122,6 +125,13 @@ class PanelWebhookService:
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
@@ -142,6 +152,30 @@ class PanelWebhookService:
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
self,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
return web.Response(status=401, text="unauthorized")
|
||||
|
||||
@@ -12,6 +12,8 @@ from db.models import User, Subscription
|
||||
|
||||
from config.settings import Settings
|
||||
from .panel_api_service import PanelApiService
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_payment_success
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
@@ -425,6 +427,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
try:
|
||||
@@ -446,6 +449,7 @@ class SubscriptionService:
|
||||
expire_at=end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
@@ -574,6 +578,16 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="traffic",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=None,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -736,6 +750,16 @@ class SubscriptionService:
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="subscription",
|
||||
months=months_int,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=final_end_date,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
"end_date": final_end_date,
|
||||
@@ -1067,6 +1091,61 @@ class SubscriptionService:
|
||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||
return True
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
"yookassa": "YooKassa",
|
||||
"freekassa": "FreeKassa",
|
||||
"platega": "Platega",
|
||||
"severpay": "SeverPay",
|
||||
"cryptopay": "Crypto Pay",
|
||||
"crypto_pay": "Crypto Pay",
|
||||
"stars": "Telegram Stars",
|
||||
"tribute": "Tribute",
|
||||
}
|
||||
|
||||
async def _send_payment_success_email(
|
||||
self,
|
||||
*,
|
||||
db_user: User,
|
||||
sale_mode: str,
|
||||
months: int,
|
||||
traffic_gb: Optional[float],
|
||||
payment_amount: float,
|
||||
end_date: Optional[datetime],
|
||||
provider: str,
|
||||
) -> None:
|
||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||
the user's email aren't set. Failures are logged and swallowed so the
|
||||
payment flow is never blocked by mail delivery."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
recipient = (db_user.email or "").strip() if db_user else ""
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||
|
||||
try:
|
||||
content = render_payment_success(
|
||||
self.settings,
|
||||
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
|
||||
sale_mode=sale_mode,
|
||||
months=int(months or 0),
|
||||
traffic_gb=traffic_gb,
|
||||
amount=float(payment_amount or 0),
|
||||
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send payment success email to user %s", db_user.user_id
|
||||
)
|
||||
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
|
||||
@@ -86,6 +86,7 @@ class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_emoji: str
|
||||
session_ttl_seconds: int
|
||||
session_secret: str
|
||||
webhook_secret_token: str
|
||||
@@ -313,6 +314,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs")
|
||||
CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled")
|
||||
@@ -329,11 +331,24 @@ class Settings(BaseSettings):
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.",
|
||||
)
|
||||
TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.",
|
||||
)
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field(
|
||||
default="write",
|
||||
description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.",
|
||||
)
|
||||
|
||||
SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
|
||||
SMTP_PORT: int = Field(default=587)
|
||||
@@ -473,6 +488,7 @@ class Settings(BaseSettings):
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||
@@ -891,6 +907,8 @@ class Settings(BaseSettings):
|
||||
'USER_AGREEMENT_URL',
|
||||
'SUBSCRIPTION_MINI_APP_URL',
|
||||
'WEBAPP_LOGO_URL',
|
||||
'TELEGRAM_OAUTH_CLIENT_SECRET',
|
||||
'TELEGRAM_OAUTH_REQUEST_ACCESS',
|
||||
'SMTP_USERNAME',
|
||||
'SMTP_PASSWORD',
|
||||
'SMTP_FROM_EMAIL',
|
||||
|
||||
@@ -282,6 +282,27 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0010_add_email_magic_token_hash(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("email_verification_codes")}
|
||||
|
||||
if "magic_token_hash" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE email_verification_codes ADD COLUMN magic_token_hash VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_email_verification_codes_magic_token_hash
|
||||
ON email_verification_codes (magic_token_hash)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0009_add_composite_indexes(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
@@ -355,6 +376,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Add composite indexes for subscription and payment lookups",
|
||||
upgrade=_migration_0009_add_composite_indexes,
|
||||
),
|
||||
Migration(
|
||||
id="0010_add_email_magic_token_hash",
|
||||
description="Store hashed magic-link tokens for email login deeplinks",
|
||||
upgrade=_migration_0010_add_email_magic_token_hash,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ class EmailVerificationCode(Base):
|
||||
code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
email = Column(String, nullable=False, index=True)
|
||||
code_hash = Column(String, nullable=False)
|
||||
magic_token_hash = Column(String, nullable=True, index=True)
|
||||
purpose = Column(String, nullable=False, index=True)
|
||||
target_user_id = Column(
|
||||
BigInteger,
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Тарифы
|
||||
|
||||
В Minishop два режима тарификации, и они **взаимоисключающие**: бот в каждый момент времени продаёт либо подписку на срок (1 / 3 / 6 / 12 месяцев), либо пакеты трафика. Ниже — что именно настраивается, что происходит при покупке и как переключаться.
|
||||
|
||||
## Краткое сравнение
|
||||
|
||||
| | Подписка по времени | Пакеты трафика |
|
||||
| --- | --- | --- |
|
||||
| Что покупает пользователь | Период доступа (1/3/6/12 мес.) | Объём трафика в ГБ |
|
||||
| Срок действия в панели | До конца купленного периода | До 01.01.2099 (фактически бессрочно) |
|
||||
| Лимит трафика | Из `USER_TRAFFIC_LIMIT_GB`, сбрасывается по `USER_TRAFFIC_STRATEGY` | Кумулятивно: каждый платёж **прибавляет** ГБ к лимиту |
|
||||
| Стратегия сброса | `NO_RESET` / `WEEK` / `MONTH` / `DAY` | Принудительно `NO_RESET` |
|
||||
| Автопродление YooKassa | Поддерживается | Выключено |
|
||||
| Реферальные бонусы | Доступны (бонусные дни) | Не выдаются |
|
||||
| Триал | Доступен (`TRIAL_*`) | Доступен (включается отдельно) |
|
||||
|
||||
Бот определяет режим по наличию переменных `TRAFFIC_PACKAGES` или `STARS_TRAFFIC_PACKAGES`: если хотя бы одна из них непустая — включается режим продажи трафика и переменные `*_MONTHS_ENABLED` / `RUB_PRICE_*` игнорируются.
|
||||
|
||||
## Режим «Подписка по времени» (по умолчанию)
|
||||
|
||||
Используется, когда `TRAFFIC_PACKAGES` и `STARS_TRAFFIC_PACKAGES` пусты.
|
||||
|
||||
### Цены и периоды
|
||||
|
||||
Для каждого периода настраивается доступность и две цены — в основной валюте (`DEFAULT_CURRENCY_SYMBOL`, по умолчанию RUB) и в Telegram Stars:
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `1_MONTH_ENABLED` / `3_MONTHS_ENABLED` / `6_MONTHS_ENABLED` / `12_MONTHS_ENABLED` | `true`/`false` — показывать ли период в выборе |
|
||||
| `RUB_PRICE_1_MONTH` / `RUB_PRICE_3_MONTHS` / `RUB_PRICE_6_MONTHS` / `RUB_PRICE_12_MONTHS` | Цена в рублях. Если не задана — кнопка не появится |
|
||||
| `STARS_PRICE_1_MONTH` / `STARS_PRICE_3_MONTHS` / `STARS_PRICE_6_MONTHS` / `STARS_PRICE_12_MONTHS` | Цена в Stars. Используется, когда оплата идёт через `STARS_ENABLED` |
|
||||
|
||||
Если `RUB_PRICE_*` равно `0` — соответствующий период просто скрывается.
|
||||
|
||||
### Трафик пользователя
|
||||
|
||||
Лимит трафика и стратегия его сброса настраиваются глобально и применяются ко всем платным пользователям (в том числе при продлении):
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `USER_TRAFFIC_LIMIT_GB` | Лимит трафика в ГБ. `0` — безлимит |
|
||||
| `USER_TRAFFIC_STRATEGY` | Когда сбрасывается счётчик: `NO_RESET`, `DAY`, `WEEK`, `MONTH` |
|
||||
|
||||
### Что происходит при оплате
|
||||
|
||||
1. Если у пользователя ещё нет активной подписки — стартовая дата = «сейчас». Если есть — новая длительность прибавляется к её `end_date` (продление, а не перезапись).
|
||||
2. К итогу могут добавиться бонусные дни промокода (`promo_codes`) и реферальной программы (`REFERRAL_BONUS_DAYS_*`, `REFEREE_BONUS_DAYS_*`).
|
||||
3. В панели Remnawave у пользователя обновляются `expireAt`, `trafficLimitBytes` и `trafficLimitStrategy` под текущие настройки.
|
||||
|
||||
### Автопродление (только YooKassa)
|
||||
|
||||
Включается через `YOOKASSA_AUTOPAYMENTS_ENABLED=true`. Если включено и пользователь сохранил карту, то за `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` дней до окончания бот пытается списать сумму, равную `RUB_PRICE_*` для длительности из последнего платежа. См. `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` для управления чекбоксом «сохранить карту».
|
||||
|
||||
В режиме трафика автопродление принудительно выключается, даже если YooKassa-настройки разрешают его.
|
||||
|
||||
### Уведомления
|
||||
|
||||
Управляются `SUBSCRIPTION_NOTIFICATIONS_ENABLED`, `SUBSCRIPTION_NOTIFY_ON_EXPIRE`, `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`, `SUBSCRIPTION_NOTIFY_DAYS_BEFORE`. Работают по `end_date` подписки — поэтому в режиме трафика, где `end_date` всегда «2099», они эффективно не отправляются.
|
||||
|
||||
## Режим «Пакеты трафика»
|
||||
|
||||
Включается заданием переменной `TRAFFIC_PACKAGES` (и/или `STARS_TRAFFIC_PACKAGES`) в `.env`.
|
||||
|
||||
### Формат
|
||||
|
||||
```env
|
||||
# Пакеты в основной валюте (RUB), формат "<GB>:<цена>", через запятую
|
||||
TRAFFIC_PACKAGES=10:199,50:799,200:1999
|
||||
|
||||
# Пакеты в Telegram Stars (опционально, если включён STARS_ENABLED)
|
||||
STARS_TRAFFIC_PACKAGES=10:2500,50:9000
|
||||
```
|
||||
|
||||
Для дробных размеров допускается `0.5:99`, `1.5:299` и т. п. Цены допускают `0` (например, для теста).
|
||||
|
||||
Если задана только `STARS_TRAFFIC_PACKAGES`, при выборе пакета бот покажет цену в звёздах и автоматически подменит символ валюты на `⭐`. Если фиатные провайдеры (`YOOKASSA_ENABLED`, `FREEKASSA_ENABLED`, `PLATEGA_ENABLED`, `SEVERPAY_ENABLED`, `CRYPTOPAY_ENABLED`) включены, но `TRAFFIC_PACKAGES` не задана — бот сочтёт это ошибкой конфигурации и не пропустит покупку.
|
||||
|
||||
### Что происходит при оплате
|
||||
|
||||
1. К текущему лимиту трафика пользователя в панели **прибавляется** купленный объём (`new_limit = current_limit + purchase_bytes`). Это работает и для существующих подписчиков.
|
||||
2. Стратегия сброса принудительно ставится в `NO_RESET` — иначе докупленные ГБ обнулялись бы по расписанию.
|
||||
3. `end_date` ставится в `2099-01-01 UTC` (или сохраняется более поздняя, если по какой-то причине уже была). Это нужно, чтобы Remnawave не помечал пользователя как просроченного.
|
||||
4. Реферальные бонусы и автопродление при таких платежах отключены: даже если в `.env` они настроены, в этом режиме бот их не применяет.
|
||||
5. Поле `subscription_duration_months` в БД и в CSV-выгрузке платежей в этом режиме интерпретируется как «количество ГБ», а не как месяцы. В UI и админке это учитывается, но имейте в виду при ручных запросах к БД.
|
||||
|
||||
### Триал в режиме трафика
|
||||
|
||||
Триал работает по той же логике, что и в режиме подписок: задаётся `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY`. То есть пробный период всё ещё ограничен по времени, даже если основная продажа — это пакеты трафика.
|
||||
|
||||
## Как переключиться между режимами
|
||||
|
||||
1. В `.env` либо заполните `TRAFFIC_PACKAGES` (включится режим трафика), либо очистите её (включится режим подписок).
|
||||
2. Перезапустите контейнер: `docker compose up -d`.
|
||||
3. Существующие подписчики не теряют доступ:
|
||||
- При переключении в режим трафика их `end_date` остаётся прежним до следующей покупки. Покупка пакета установит `end_date` = «2099» и переведёт стратегию в `NO_RESET`.
|
||||
- При обратном переключении следующий платёж задаст обычный `end_date` от «сейчас» (или продлит существующий).
|
||||
|
||||
> ⚠️ Одновременно совмещать продажу подписок и трафика бот **не умеет**. Проверка идёт по одному булевому флагу `traffic_sale_mode`.
|
||||
|
||||
## Чеки для самозанятых (nalog.ru)
|
||||
|
||||
Для каждого режима используется отдельный шаблон названия в чеке:
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `NALOGO_RECEIPT_NAME_SUBSCRIPTION` | Шаблон для подписки. Поддерживает `{months}` |
|
||||
| `NALOGO_RECEIPT_NAME_TRAFFIC` | Шаблон для пакета трафика. Поддерживает `{gb}` |
|
||||
|
||||
Пример: `NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB`.
|
||||
|
||||
## Связанные переменные
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `DEFAULT_CURRENCY_SYMBOL` | Что показывается рядом с фиатной ценой (`RUB`, `USD`, `EUR`, …) |
|
||||
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты — общий для обоих режимов |
|
||||
| `STARS_ENABLED` | Без него Stars-цены не предложатся, даже если заданы |
|
||||
| `USER_HWID_DEVICE_LIMIT` | Лимит устройств — общий для обоих режимов, к тарификации не относится |
|
||||
|
||||
## Где это всё в коде
|
||||
|
||||
- Чтение и парсинг переменных: [config/settings.py](../config/settings.py) — `subscription_options`, `traffic_packages`, `stars_traffic_packages`, `traffic_sale_mode`.
|
||||
- Активация платежа: [bot/services/subscription_service.py](../bot/services/subscription_service.py) — `activate_subscription` (ветка по `sale_mode`) и `_activate_traffic_package`.
|
||||
- Выбор тарифа в боте: [bot/handlers/user/subscription/core.py](../bot/handlers/user/subscription/core.py) и [bot/handlers/user/subscription/payments_subscription.py](../bot/handlers/user/subscription/payments_subscription.py).
|
||||
- Web App: [bot/app/web/subscription_webapp.py](../bot/app/web/subscription_webapp.py) — поле `traffic_mode` в payload и логика отображения трафика.
|
||||
+209
-4
@@ -11,6 +11,7 @@
|
||||
"menu_activate_trial_button": "🆓 Free Trial",
|
||||
"menu_subscribe_inline": "🚀 Purchase",
|
||||
"menu_personal_account_button": "🔑 Personal Account",
|
||||
"menu_bot_interface_button": "🤖 Bot menu",
|
||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
|
||||
"menu_referral_inline": "🎁 Invite friend",
|
||||
@@ -22,6 +23,7 @@
|
||||
"menu_terms_button": "📄 Terms of Service",
|
||||
"menu_info_button": "ℹ️ Information",
|
||||
"bot_interface_menu_title": "Bot interface",
|
||||
"bot_interface_menu_webapp_hint": "The Personal Account is the easier place to manage the service.",
|
||||
"info_links_message": "Choose a document:",
|
||||
"privacy_policy_button": "🔒 Privacy Policy",
|
||||
"user_agreement_button": "📄 User Agreement",
|
||||
@@ -57,7 +59,6 @@
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
|
||||
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||
@@ -67,7 +68,6 @@
|
||||
"no_devices_found": "Error loading device list.",
|
||||
"devices_unlimited_label": "Unlimited",
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_description_traffic": "Traffic package {traffic_gb} GB",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
@@ -102,11 +102,13 @@
|
||||
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
|
||||
"yes_button": "Yes",
|
||||
"no_button": "No",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Telegram link:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
|
||||
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
|
||||
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
|
||||
"referral_share_message_button": "📩 Message for friend",
|
||||
"referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
|
||||
"referral_friend_message_with_webapp": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}",
|
||||
"referral_webapp_link_line": "\n🌐 Web link:\n<code>{webapp_referral_link}</code>",
|
||||
"friend_placeholder": "friend",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||
@@ -334,6 +336,9 @@
|
||||
"log_open_profile_link": "👤 Open profile",
|
||||
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_new_email_user_registration": "📧 <b>New User (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Email linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Telegram linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
@@ -518,5 +523,205 @@
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n",
|
||||
"email_footer_auto": "Sent automatically by {brand}. Please don't reply.",
|
||||
"email_login_code_subject": "{code} — your sign-in code",
|
||||
"email_login_code_preheader": "Your one-time code expires in {minutes} min.",
|
||||
"email_login_code_heading": "Confirm your sign-in",
|
||||
"email_login_code_intro": "Use this 6-digit code to finish signing in to your subscription dashboard.",
|
||||
"email_login_code_expiry_html": "The code is valid for <strong style=\"color:#e6e9ef;\">{minutes} min</strong>.",
|
||||
"email_login_code_security": "If you didn't request this code, you can ignore this email — your account stays safe.",
|
||||
"email_login_code_magic_cta": "Sign in with one tap",
|
||||
"email_login_code_magic_intro": "Don't want to type the code? Sign in with this link.",
|
||||
"email_login_code_magic_hint": "The link is single-use and expires together with the code.",
|
||||
"email_login_code_magic_or": "or",
|
||||
"email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.",
|
||||
"email_login_code_text_magic": "Or sign in with this single-use link: {url}",
|
||||
"email_account_merged_subject": "Accounts merged",
|
||||
"email_account_merged_preheader": "Your accounts were combined into one profile.",
|
||||
"email_account_merged_heading": "Accounts merged",
|
||||
"email_account_merged_intro": "We merged your accounts into a single profile so you keep one subscription and one history of payments.",
|
||||
"email_account_merged_row_kept": "Kept account",
|
||||
"email_account_merged_row_removed": "Removed account",
|
||||
"email_account_merged_row_end_date": "New end date",
|
||||
"email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.",
|
||||
"email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.",
|
||||
"email_payment_success_subject": "Payment received",
|
||||
"email_payment_success_preheader": "Thanks — your subscription has been activated.",
|
||||
"email_payment_success_heading": "Payment received",
|
||||
"email_payment_success_intro_subscription": "Thanks! Your subscription has been extended by {months} month(s).",
|
||||
"email_payment_success_intro_traffic": "Thanks! We added {traffic_gb} GB of traffic to your account.",
|
||||
"email_payment_success_row_period": "Period",
|
||||
"email_payment_success_row_traffic": "Traffic added",
|
||||
"email_payment_success_row_amount": "Amount",
|
||||
"email_payment_success_row_end_date": "Active until",
|
||||
"email_payment_success_row_method": "Method",
|
||||
"email_payment_success_period_value": "{months} month(s)",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} GB",
|
||||
"email_payment_success_cta": "Open dashboard",
|
||||
"email_payment_success_footer_note": "We'll remind you a few days before the subscription ends.",
|
||||
"email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Dashboard: {url}",
|
||||
"email_subscription_expiring_subject_today": "Your subscription ends today",
|
||||
"email_subscription_expiring_subject_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_subject_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_heading_today": "Subscription ends today",
|
||||
"email_subscription_expiring_heading_tomorrow": "1 day left on your subscription",
|
||||
"email_subscription_expiring_heading_days": "{days} days left on your subscription",
|
||||
"email_subscription_expiring_preheader_today": "Renew today to avoid disconnection.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Your subscription expires tomorrow.",
|
||||
"email_subscription_expiring_preheader_days": "Your subscription expires in {days} days.",
|
||||
"email_subscription_expiring_intro_today": "Your subscription is about to expire. Renew now to keep your connection active without interruption.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Your subscription expires tomorrow. Renew now to avoid losing access.",
|
||||
"email_subscription_expiring_intro_days": "A heads-up so you can renew in advance and avoid any interruption.",
|
||||
"email_subscription_expiring_row_days_left": "Days left",
|
||||
"email_subscription_expiring_row_end_date": "Active until",
|
||||
"email_subscription_expiring_cta": "Renew subscription",
|
||||
"email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.",
|
||||
"email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Renew: {url}",
|
||||
"wa_loading": "Loading...",
|
||||
"wa_back": "Back",
|
||||
"wa_close": "Close",
|
||||
"wa_auth_checking_login": "Checking sign-in...",
|
||||
"wa_auth_login_confirm_failed": "Could not confirm sign-in",
|
||||
"wa_auth_checking_telegram": "Checking Telegram...",
|
||||
"wa_auth_access_denied": "Access denied",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed",
|
||||
"wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable",
|
||||
"wa_auth_telegram_not_configured": "Telegram sign-in is not configured",
|
||||
"wa_auth_telegram_cancelled": "Telegram sign-in was cancelled",
|
||||
"wa_auth_invalid_email": "Enter a valid email",
|
||||
"wa_auth_sending_code": "Sending code...",
|
||||
"wa_auth_send_code_failed": "Failed to send code",
|
||||
"wa_auth_enter_code_6digits": "Enter the 6-digit code from the email",
|
||||
"wa_auth_checking_code": "Checking code...",
|
||||
"wa_auth_invalid_code": "Invalid code",
|
||||
"wa_auth_code_expired": "Code expired",
|
||||
"wa_auth_resend_wait": "You can resend in {seconds} sec.",
|
||||
"wa_email_verification_title": "Email confirmation",
|
||||
"wa_email_sent_to": "We sent a code to {email}",
|
||||
"wa_email_code_aria": "Verification code",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Confirm",
|
||||
"wa_resend_code": "Resend code",
|
||||
"wa_send_code_email": "Send code to email",
|
||||
"wa_or": "or",
|
||||
"wa_login_telegram_button": "Sign in with Telegram",
|
||||
"wa_auth_legal_intro": "By creating an account, you agree to the",
|
||||
"wa_auth_legal_privacy": "privacy policy",
|
||||
"wa_auth_legal_and": "and",
|
||||
"wa_auth_legal_agreement": "user agreement",
|
||||
"wa_home_subscription_active": "Subscription active",
|
||||
"wa_home_access_active": "Access active",
|
||||
"wa_sub_term_forever": "Forever",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "day",
|
||||
"wa_sub_term_day_few": "days",
|
||||
"wa_sub_term_day_many": "days",
|
||||
"wa_sub_term_month_one": "month",
|
||||
"wa_sub_term_month_few": "months",
|
||||
"wa_sub_term_month_many": "months",
|
||||
"wa_sub_term_year_one": "year",
|
||||
"wa_sub_term_year_few": "years",
|
||||
"wa_sub_term_year_many": "years",
|
||||
"wa_home_subscription_inactive": "Subscription inactive",
|
||||
"wa_until_date": "until {date}",
|
||||
"wa_home_traffic_used": "Traffic used",
|
||||
"wa_renew": "Renew",
|
||||
"wa_pay_subscription": "Pay subscription",
|
||||
"wa_subscription_title": "Subscription",
|
||||
"wa_subscription_choose_period": "Choose subscription period",
|
||||
"wa_per_month_short": "/mo",
|
||||
"wa_traffic_packages_title": "Traffic",
|
||||
"wa_traffic_packages_choose": "Choose traffic package",
|
||||
"wa_per_gb_short": "/GB",
|
||||
"wa_buy_traffic": "Buy traffic",
|
||||
"wa_activate_trial": "Activate trial",
|
||||
"wa_trial_title": "Trial period",
|
||||
"wa_trial_details": "{days} days, {traffic}",
|
||||
"wa_trial_activated": "Trial activated",
|
||||
"wa_trial_activation_failed": "Failed to activate trial",
|
||||
"wa_install_and_configure": "Install and configure",
|
||||
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
|
||||
"wa_pay": "Pay",
|
||||
"wa_referral_link_title": "Your referral link",
|
||||
"wa_link_unavailable": "Link is not available yet",
|
||||
"wa_link_copied": "Link copied",
|
||||
"wa_copy": "Copy",
|
||||
"wa_referral_bonus_overview_title": "Bonus system",
|
||||
"wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.",
|
||||
"wa_referral_bonus_registration_title": "For registration via referral link",
|
||||
"wa_referral_bonus_paid_intro": "If a friend pays for a subscription:",
|
||||
"wa_referral_bonus_you_days": "You: +{days} days",
|
||||
"wa_referral_bonus_friend_days": "Friend: +{days} days",
|
||||
"wa_referral_bonus_not_configured": "Referral bonuses are not configured",
|
||||
"wa_promo_enter": "Enter promo code",
|
||||
"wa_promo_activated_until": "Promo code activated. Subscription until {date}",
|
||||
"wa_promo_activated": "Promo code activated",
|
||||
"wa_promo_activation_failed": "Failed to activate promo code",
|
||||
"wa_activate": "Activate",
|
||||
"wa_settings_avatar_alt": "User avatar",
|
||||
"wa_settings_language": "Language",
|
||||
"wa_settings_link_telegram": "Telegram linked",
|
||||
"wa_settings_not_linked": "Not linked",
|
||||
"wa_settings_link_email": "Email linked",
|
||||
"wa_settings_email_not_linked": "Not linked",
|
||||
"wa_settings_linked": "Linked",
|
||||
"wa_settings_language_update_failed": "Failed to update language",
|
||||
"wa_settings_user_agreement": "User agreement",
|
||||
"wa_settings_privacy_policy": "Privacy policy",
|
||||
"wa_logout": "Log out",
|
||||
"wa_end_session": "End session",
|
||||
"wa_navigation": "Navigation",
|
||||
"wa_nav_home": "Home",
|
||||
"wa_nav_settings": "Settings",
|
||||
"wa_nav_devices": "Devices",
|
||||
"wa_devices_title": "My devices",
|
||||
"wa_devices_count": "{current} of {max}",
|
||||
"wa_devices_unlimited": "Unlimited",
|
||||
"wa_devices_refresh": "Refresh devices",
|
||||
"wa_devices_loading": "Loading devices...",
|
||||
"wa_devices_load_failed": "Failed to load devices",
|
||||
"wa_devices_empty": "No devices yet",
|
||||
"wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.",
|
||||
"wa_device_fallback_name": "Device {index}",
|
||||
"wa_devices_platform_unknown": "Platform unknown",
|
||||
"wa_devices_connected_at": "Connected",
|
||||
"wa_devices_disconnect": "Disconnect device",
|
||||
"wa_devices_disconnect_title": "Disconnect device",
|
||||
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
|
||||
"wa_devices_disconnect_confirm": "Disconnect",
|
||||
"wa_device_disconnected": "Device disconnected",
|
||||
"wa_device_disconnect_failed": "Failed to disconnect device",
|
||||
"wa_cancel": "Cancel",
|
||||
"wa_payment_created": "Payment created",
|
||||
"wa_payment_create_failed": "Failed to create payment",
|
||||
"wa_connect_link_unavailable": "Connection link is not available yet",
|
||||
"wa_copied": "Copied",
|
||||
"wa_unavailable": "Not available yet",
|
||||
"wa_method_other_title": "Other methods",
|
||||
"wa_unlimited_traffic": "Unlimited traffic",
|
||||
"wa_traffic_of": "{used} of {limit}",
|
||||
"wa_language_default": "English",
|
||||
"wa_telegram_not_linked": "Telegram not linked",
|
||||
"wa_tg_id_not_linked": "TG ID not linked",
|
||||
"wa_nav_bonuses": "Bonuses",
|
||||
"wa_method_platega_sbp_card": "SBP/Card",
|
||||
"wa_method_platega_crypto": "Crypto",
|
||||
"wa_settings_link_telegram_action": "Link Telegram",
|
||||
"wa_settings_telegram_linked_title": "Telegram linked",
|
||||
"wa_settings_link_email_action": "Link email",
|
||||
"wa_settings_email_linked_title": "Email linked",
|
||||
"wa_activate_promo_title": "Activate promo code",
|
||||
"wa_link_email_modal_title": "Link email",
|
||||
"wa_traffic_reset_none": "No reset",
|
||||
"wa_traffic_reset_monthly": "Monthly reset",
|
||||
"wa_traffic_reset_weekly": "Weekly reset",
|
||||
"wa_traffic_reset_daily": "Daily reset",
|
||||
"wa_traffic_reset_yearly": "Yearly reset",
|
||||
"wa_traffic_reset_policy": "Traffic reset policy",
|
||||
"wa_plan_one_year": "1 year",
|
||||
"wa_link_email_modal_desc": "Enter your email and get a verification code"
|
||||
}
|
||||
|
||||
+209
-4
@@ -11,6 +11,7 @@
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_personal_account_button": "🔑 Личный кабинет",
|
||||
"menu_bot_interface_button": "🤖 Бот-меню",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"no_subscription_options_available": "Выдача подписки не настроена администратором бота",
|
||||
"menu_referral_inline": "🎁 Пригласить друга",
|
||||
@@ -22,6 +23,7 @@
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
"menu_info_button": "ℹ️ Информация",
|
||||
"bot_interface_menu_title": "Интерфейс в боте",
|
||||
"bot_interface_menu_webapp_hint": "В личном кабинете взаимодействовать с сервисом будет удобнее.",
|
||||
"info_links_message": "Выберите документ:",
|
||||
"privacy_policy_button": "🔒 Политика конфиденциальности",
|
||||
"user_agreement_button": "📄 Пользовательское соглашение",
|
||||
@@ -56,7 +58,6 @@
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||
@@ -66,7 +67,6 @@
|
||||
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||
"devices_unlimited_label": "Без ограничений",
|
||||
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_description_traffic": "Пакет трафика {traffic_gb} ГБ",
|
||||
@@ -102,11 +102,13 @@
|
||||
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
|
||||
"yes_button": "Да",
|
||||
"no_button": "Нет",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Telegram ссылка:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
|
||||
"referral_share_message_button": "📩 Сообщение для друга",
|
||||
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
|
||||
"referral_friend_message_with_webapp": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}",
|
||||
"referral_webapp_link_line": "\n🌐 Web ссылка:\n<code>{webapp_referral_link}</code>",
|
||||
"friend_placeholder": "друг",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
@@ -334,6 +336,9 @@
|
||||
"log_open_profile_link": "👤 Открыть профиль",
|
||||
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_new_email_user_registration": "📧 <b>Новый пользователь (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Привязана почта</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Привязан Telegram</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
@@ -518,5 +523,205 @@
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n",
|
||||
"email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.",
|
||||
"email_login_code_subject": "{code} — код для входа",
|
||||
"email_login_code_preheader": "Одноразовый код действует {minutes} мин.",
|
||||
"email_login_code_heading": "Подтвердите вход",
|
||||
"email_login_code_intro": "Введите этот 6-значный код, чтобы завершить вход в личный кабинет подписки.",
|
||||
"email_login_code_expiry_html": "Код действует <strong style=\"color:#e6e9ef;\">{minutes} мин</strong>.",
|
||||
"email_login_code_security": "Если вы не запрашивали код, просто проигнорируйте это письмо — ваш аккаунт в безопасности.",
|
||||
"email_login_code_magic_cta": "Войти одним нажатием",
|
||||
"email_login_code_magic_intro": "Не хотите вводить код? Войдите по ссылке.",
|
||||
"email_login_code_magic_hint": "Ссылка одноразовая и действует столько же, сколько код.",
|
||||
"email_login_code_magic_or": "или",
|
||||
"email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.",
|
||||
"email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}",
|
||||
"email_account_merged_subject": "Аккаунты объединены",
|
||||
"email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.",
|
||||
"email_account_merged_heading": "Аккаунты объединены",
|
||||
"email_account_merged_intro": "Мы объединили ваши аккаунты в один профиль — теперь у вас одна подписка и общая история оплат.",
|
||||
"email_account_merged_row_kept": "Оставлен аккаунт",
|
||||
"email_account_merged_row_removed": "Удалён аккаунт",
|
||||
"email_account_merged_row_end_date": "Новая дата окончания",
|
||||
"email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.",
|
||||
"email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.",
|
||||
"email_payment_success_subject": "Платёж получен",
|
||||
"email_payment_success_preheader": "Спасибо — подписка активирована.",
|
||||
"email_payment_success_heading": "Платёж получен",
|
||||
"email_payment_success_intro_subscription": "Спасибо! Ваша подписка продлена на {months} мес.",
|
||||
"email_payment_success_intro_traffic": "Спасибо! Мы зачислили {traffic_gb} ГБ трафика на ваш аккаунт.",
|
||||
"email_payment_success_row_period": "Период",
|
||||
"email_payment_success_row_traffic": "Трафик",
|
||||
"email_payment_success_row_amount": "Сумма",
|
||||
"email_payment_success_row_end_date": "Действует до",
|
||||
"email_payment_success_row_method": "Способ оплаты",
|
||||
"email_payment_success_period_value": "{months} мес.",
|
||||
"email_payment_success_traffic_value": "{traffic_gb} ГБ",
|
||||
"email_payment_success_cta": "Открыть кабинет",
|
||||
"email_payment_success_footer_note": "За несколько дней до окончания мы напомним о продлении.",
|
||||
"email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.",
|
||||
"email_payment_success_text_dashboard": "Кабинет: {url}",
|
||||
"email_subscription_expiring_subject_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.",
|
||||
"email_subscription_expiring_heading_today": "Подписка заканчивается сегодня",
|
||||
"email_subscription_expiring_heading_tomorrow": "Подписка заканчивается завтра",
|
||||
"email_subscription_expiring_heading_days": "До конца подписки {days} дн.",
|
||||
"email_subscription_expiring_preheader_today": "Продлите сегодня, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_preheader_tomorrow": "Подписка истекает завтра.",
|
||||
"email_subscription_expiring_preheader_days": "Подписка заканчивается через {days} дн.",
|
||||
"email_subscription_expiring_intro_today": "Ваша подписка вот-вот истечёт. Продлите её, чтобы соединение не прерывалось.",
|
||||
"email_subscription_expiring_intro_tomorrow": "Ваша подписка истекает завтра. Продлите её, чтобы не потерять доступ.",
|
||||
"email_subscription_expiring_intro_days": "Напоминаем заранее, чтобы вы успели продлить подписку без перерыва в работе.",
|
||||
"email_subscription_expiring_row_days_left": "Осталось дней",
|
||||
"email_subscription_expiring_row_end_date": "Действует до",
|
||||
"email_subscription_expiring_cta": "Продлить подписку",
|
||||
"email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.",
|
||||
"email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.",
|
||||
"email_subscription_expiring_text_renew": "Продлить: {url}",
|
||||
"wa_loading": "Загрузка...",
|
||||
"wa_back": "Назад",
|
||||
"wa_close": "Закрыть",
|
||||
"wa_auth_checking_login": "Проверяем вход...",
|
||||
"wa_auth_login_confirm_failed": "Не удалось подтвердить вход",
|
||||
"wa_auth_checking_telegram": "Проверяем Telegram...",
|
||||
"wa_auth_access_denied": "Доступ запрещен",
|
||||
"wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден",
|
||||
"wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен",
|
||||
"wa_auth_telegram_not_configured": "Telegram-вход не настроен",
|
||||
"wa_auth_telegram_cancelled": "Вход через Telegram отменён",
|
||||
"wa_auth_invalid_email": "Введите корректный email",
|
||||
"wa_auth_sending_code": "Отправляем код...",
|
||||
"wa_auth_send_code_failed": "Не удалось отправить код",
|
||||
"wa_auth_enter_code_6digits": "Введите 6 цифр из письма",
|
||||
"wa_auth_checking_code": "Проверяем код...",
|
||||
"wa_auth_invalid_code": "Неверный код",
|
||||
"wa_auth_code_expired": "Код устарел",
|
||||
"wa_auth_resend_wait": "Повторная отправка через {seconds} сек.",
|
||||
"wa_email_verification_title": "Подтверждение по email",
|
||||
"wa_email_sent_to": "Мы отправили код на {email}",
|
||||
"wa_email_code_aria": "Код подтверждения",
|
||||
"wa_email_placeholder": "Email",
|
||||
"wa_confirm": "Подтвердить",
|
||||
"wa_resend_code": "Отправить код повторно",
|
||||
"wa_send_code_email": "Отправить код на почту",
|
||||
"wa_or": "или",
|
||||
"wa_login_telegram_button": "Войти через телеграм",
|
||||
"wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с",
|
||||
"wa_auth_legal_privacy": "политикой конфиденциальности",
|
||||
"wa_auth_legal_and": "и",
|
||||
"wa_auth_legal_agreement": "пользовательским соглашением",
|
||||
"wa_home_subscription_active": "Подписка активна",
|
||||
"wa_home_access_active": "Доступ активен",
|
||||
"wa_sub_term_forever": "Навсегда",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "день",
|
||||
"wa_sub_term_day_few": "дня",
|
||||
"wa_sub_term_day_many": "дней",
|
||||
"wa_sub_term_month_one": "месяц",
|
||||
"wa_sub_term_month_few": "месяца",
|
||||
"wa_sub_term_month_many": "месяцев",
|
||||
"wa_sub_term_year_one": "год",
|
||||
"wa_sub_term_year_few": "года",
|
||||
"wa_sub_term_year_many": "лет",
|
||||
"wa_home_subscription_inactive": "Подписка не активна",
|
||||
"wa_until_date": "до {date}",
|
||||
"wa_home_traffic_used": "Использовано трафика",
|
||||
"wa_renew": "Продлить",
|
||||
"wa_pay_subscription": "Оплатить подписку",
|
||||
"wa_subscription_title": "Подписка",
|
||||
"wa_subscription_choose_period": "Выберите срок подписки",
|
||||
"wa_per_month_short": "/мес",
|
||||
"wa_traffic_packages_title": "Трафик",
|
||||
"wa_traffic_packages_choose": "Выберите пакет трафика",
|
||||
"wa_per_gb_short": "/ГБ",
|
||||
"wa_buy_traffic": "Купить трафик",
|
||||
"wa_activate_trial": "Активировать триал",
|
||||
"wa_trial_title": "Пробный период",
|
||||
"wa_trial_details": "{days} дн., {traffic}",
|
||||
"wa_trial_activated": "Пробный период активирован",
|
||||
"wa_trial_activation_failed": "Не удалось активировать пробный период",
|
||||
"wa_install_and_configure": "Установить и настроить",
|
||||
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
|
||||
"wa_pay": "Оплатить",
|
||||
"wa_referral_link_title": "Ваша реферальная ссылка",
|
||||
"wa_link_unavailable": "Ссылка пока недоступна",
|
||||
"wa_link_copied": "Ссылка скопирована",
|
||||
"wa_copy": "Копировать",
|
||||
"wa_referral_bonus_overview_title": "Система бонусов",
|
||||
"wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.",
|
||||
"wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке",
|
||||
"wa_referral_bonus_paid_intro": "Если друг оплатит подписку:",
|
||||
"wa_referral_bonus_you_days": "Вам: +{days} дней",
|
||||
"wa_referral_bonus_friend_days": "Другу: +{days} дней",
|
||||
"wa_referral_bonus_not_configured": "Реферальные бонусы не настроены",
|
||||
"wa_promo_enter": "Введите промокод",
|
||||
"wa_promo_activated_until": "Промокод активирован. Подписка до {date}",
|
||||
"wa_promo_activated": "Промокод активирован",
|
||||
"wa_promo_activation_failed": "Не удалось активировать промокод",
|
||||
"wa_activate": "Активировать",
|
||||
"wa_settings_avatar_alt": "Аватар пользователя",
|
||||
"wa_settings_language": "Выбор языка",
|
||||
"wa_settings_link_telegram": "Привязка Telegram",
|
||||
"wa_settings_not_linked": "Не привязан",
|
||||
"wa_settings_link_email": "Привязка почты",
|
||||
"wa_settings_email_not_linked": "Не привязана",
|
||||
"wa_settings_linked": "Привязано",
|
||||
"wa_settings_language_update_failed": "Не удалось обновить язык",
|
||||
"wa_settings_user_agreement": "Пользовательское соглашение",
|
||||
"wa_settings_privacy_policy": "Политика конфиденциальности",
|
||||
"wa_logout": "Выйти",
|
||||
"wa_end_session": "Завершить сессию",
|
||||
"wa_navigation": "Навигация",
|
||||
"wa_nav_home": "Главная",
|
||||
"wa_nav_settings": "Настройки",
|
||||
"wa_nav_devices": "Устройства",
|
||||
"wa_devices_title": "Мои устройства",
|
||||
"wa_devices_count": "{current} из {max}",
|
||||
"wa_devices_unlimited": "Без ограничений",
|
||||
"wa_devices_refresh": "Обновить устройства",
|
||||
"wa_devices_loading": "Загружаем устройства...",
|
||||
"wa_devices_load_failed": "Не удалось загрузить устройства",
|
||||
"wa_devices_empty": "Устройств пока нет",
|
||||
"wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.",
|
||||
"wa_device_fallback_name": "Устройство {index}",
|
||||
"wa_devices_platform_unknown": "Платформа неизвестна",
|
||||
"wa_devices_connected_at": "Подключено",
|
||||
"wa_devices_disconnect": "Отключить устройство",
|
||||
"wa_devices_disconnect_title": "Отключить устройство",
|
||||
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
|
||||
"wa_devices_disconnect_confirm": "Отключить",
|
||||
"wa_device_disconnected": "Устройство отключено",
|
||||
"wa_device_disconnect_failed": "Не удалось отключить устройство",
|
||||
"wa_cancel": "Отмена",
|
||||
"wa_payment_created": "Платеж создан",
|
||||
"wa_payment_create_failed": "Не удалось создать платеж",
|
||||
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
|
||||
"wa_copied": "Скопировано",
|
||||
"wa_unavailable": "Пока недоступно",
|
||||
"wa_method_other_title": "Другие способы",
|
||||
"wa_unlimited_traffic": "Безлимитный трафик",
|
||||
"wa_traffic_of": "{used} из {limit}",
|
||||
"wa_language_default": "Русский",
|
||||
"wa_telegram_not_linked": "Telegram не привязан",
|
||||
"wa_tg_id_not_linked": "TG ID не привязан",
|
||||
"wa_nav_bonuses": "Бонусы",
|
||||
"wa_method_platega_sbp_card": "СБП/Карта",
|
||||
"wa_method_platega_crypto": "Крипта",
|
||||
"wa_settings_link_telegram_action": "Привязать телеграм",
|
||||
"wa_settings_telegram_linked_title": "Телеграм привязан",
|
||||
"wa_settings_link_email_action": "Привязать почту",
|
||||
"wa_settings_email_linked_title": "Почта привязана",
|
||||
"wa_activate_promo_title": "Активировать промокод",
|
||||
"wa_link_email_modal_title": "Привязать почту",
|
||||
"wa_traffic_reset_none": "Без сброса",
|
||||
"wa_traffic_reset_monthly": "Сброс ежемесячно",
|
||||
"wa_traffic_reset_weekly": "Сброс еженедельно",
|
||||
"wa_traffic_reset_daily": "Сброс ежедневно",
|
||||
"wa_traffic_reset_yearly": "Сброс ежегодно",
|
||||
"wa_traffic_reset_policy": "Стратегия сброса трафика",
|
||||
"wa_plan_one_year": "1 год",
|
||||
"wa_link_email_modal_desc": "Введите email и получите код подтверждения"
|
||||
}
|
||||
|
||||
Generated
+993
-1
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify",
|
||||
"build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs",
|
||||
"build:webapp:css": "npm run build:webapp:svelte",
|
||||
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
|
||||
"build:webapp": "npm run build:webapp:css && npm run build:webapp:js"
|
||||
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/cli": "4.2.4",
|
||||
"bits-ui": "^2.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"tailwindcss": "4.2.4"
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"svelte": "^5.55.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "4.2.4",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ email-validator==2.3.0
|
||||
sqlalchemy[asyncio]==2.0.45
|
||||
asyncpg==0.31.0
|
||||
aiocryptopay==0.4.8
|
||||
PyJWT[crypto]==2.10.1
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
|
||||
TARGET_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "bot"
|
||||
/ "app"
|
||||
/ "web"
|
||||
/ "templates"
|
||||
/ "telegram-web-app.js"
|
||||
)
|
||||
|
||||
|
||||
def _download(source_url: str) -> bytes:
|
||||
request = Request(
|
||||
source_url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download the latest Telegram Web App SDK into the local templates directory."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default=SOURCE_URL,
|
||||
help="Telegram Web App SDK URL to download from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
type=Path,
|
||||
default=TARGET_PATH,
|
||||
help="Output path for the vendored SDK.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = _download(args.source_url)
|
||||
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.target.write_bytes(data)
|
||||
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,70 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
|
||||
TARGET_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "bot"
|
||||
/ "app"
|
||||
/ "web"
|
||||
/ "templates"
|
||||
/ "telegram-widget.js"
|
||||
)
|
||||
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
|
||||
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
|
||||
|
||||
|
||||
def _download(source_url: str) -> bytes:
|
||||
request = Request(
|
||||
source_url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def _normalize_widget_sdk(data: bytes) -> bytes:
|
||||
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
|
||||
text = data.decode("utf-8")
|
||||
normalized = text.replace(
|
||||
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
|
||||
_PATCHED_WIDGET_ORIGIN_SNIPPET,
|
||||
1,
|
||||
)
|
||||
return normalized.encode("utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download the latest Telegram Login Widget SDK into the local templates directory."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default=SOURCE_URL,
|
||||
help="Telegram Login Widget SDK URL to download from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
type=Path,
|
||||
default=TARGET_PATH,
|
||||
help="Output path for the vendored SDK.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = _normalize_widget_sdk(_download(args.source_url))
|
||||
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.target.write_bytes(data)
|
||||
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+28
-1
@@ -7,7 +7,11 @@ from unittest.mock import AsyncMock
|
||||
from aiohttp import web
|
||||
|
||||
from bot.app.web import subscription_webapp
|
||||
from bot.app.web.webapp_auth import create_webapp_session_token
|
||||
from bot.app.web.webapp_auth import (
|
||||
create_telegram_oauth_nonce,
|
||||
create_webapp_session_token,
|
||||
verify_telegram_oauth_nonce,
|
||||
)
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.handlers.user.payment import yookassa_webhook_route
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
@@ -227,6 +231,29 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn("description_too_long", response.text)
|
||||
|
||||
def test_telegram_oauth_nonce_round_trips(self):
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_SESSION_SECRET="session-secret",
|
||||
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||
)
|
||||
nonce = create_telegram_oauth_nonce(settings, ttl_seconds=60)
|
||||
|
||||
self.assertTrue(verify_telegram_oauth_nonce(settings, nonce))
|
||||
self.assertFalse(verify_telegram_oauth_nonce(settings, nonce + "tampered"))
|
||||
|
||||
def test_telegram_oauth_client_id_defaults_to_bot_id(self):
|
||||
settings = SimpleNamespace(
|
||||
BOT_TOKEN="123456789:secret",
|
||||
TELEGRAM_OAUTH_CLIENT_ID=None,
|
||||
TELEGRAM_OAUTH_REQUEST_ACCESS="write, phone, unknown, write",
|
||||
)
|
||||
|
||||
self.assertEqual(subscription_webapp._resolve_telegram_oauth_client_id(settings), 123456789)
|
||||
self.assertEqual(
|
||||
subscription_webapp._resolve_telegram_oauth_request_access(settings),
|
||||
["write", "phone"],
|
||||
)
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
import asyncio
|
||||
|
||||
@@ -26,3 +26,14 @@ class SettingsTests(unittest.TestCase):
|
||||
self.assertTrue(settings.WEBAPP_SESSION_SECRET)
|
||||
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
|
||||
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
|
||||
|
||||
def test_trial_traffic_strategy_is_available(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TRIAL_TRAFFIC_STRATEGY="WEEK",
|
||||
)
|
||||
|
||||
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bot.app.web import subscription_webapp
|
||||
from bot.handlers.user import referral
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_bot_interface_inline_keyboard,
|
||||
get_information_links_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
get_referral_link_keyboard,
|
||||
get_subscription_options_keyboard,
|
||||
)
|
||||
|
||||
|
||||
class JsonI18nStub:
|
||||
def __init__(self):
|
||||
self.translations = json.loads(
|
||||
Path("locales/en.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def gettext(self, lang, key, **kwargs):
|
||||
text = self.translations[key]
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
|
||||
|
||||
class UserBotMenuTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.i18n = JsonI18nStub()
|
||||
self.settings = SimpleNamespace(
|
||||
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/",
|
||||
SUPPORT_LINK="https://t.me/support",
|
||||
PRIVACY_POLICY_URL="https://example.com/privacy",
|
||||
USER_AGREEMENT_URL="https://example.com/agreement",
|
||||
TERMS_OF_SERVICE_URL="",
|
||||
TRIAL_ENABLED=True,
|
||||
SERVER_STATUS_URL="",
|
||||
)
|
||||
|
||||
def _callback_data(self, markup):
|
||||
return [
|
||||
button.callback_data
|
||||
for row in markup.inline_keyboard
|
||||
for button in row
|
||||
if button.callback_data
|
||||
]
|
||||
|
||||
def test_main_menu_exposes_bot_menu_and_information(self):
|
||||
markup = get_main_menu_inline_keyboard("en", self.i18n, self.settings)
|
||||
|
||||
callbacks = self._callback_data(markup)
|
||||
|
||||
self.assertIn("main_action:bot_interface", callbacks)
|
||||
self.assertIn("main_action:info", callbacks)
|
||||
|
||||
def test_bot_interface_buttons_return_to_bot_interface(self):
|
||||
markup = get_bot_interface_inline_keyboard("en", self.i18n, self.settings)
|
||||
|
||||
callbacks = self._callback_data(markup)
|
||||
|
||||
self.assertIn("main_action:bot_subscribe", callbacks)
|
||||
self.assertIn("main_action:bot_my_subscription", callbacks)
|
||||
self.assertIn("main_action:bot_referral", callbacks)
|
||||
self.assertIn("main_action:bot_info", callbacks)
|
||||
self.assertIn("main_action:back_to_main", callbacks)
|
||||
|
||||
def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self):
|
||||
subscription_markup = get_subscription_options_keyboard(
|
||||
{1: 100},
|
||||
"RUB",
|
||||
"en",
|
||||
self.i18n,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
referral_markup = get_referral_link_keyboard(
|
||||
"en",
|
||||
self.i18n,
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
info_markup = get_information_links_keyboard(
|
||||
"en",
|
||||
self.i18n,
|
||||
"https://example.com/privacy",
|
||||
"https://example.com/agreement",
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
language_markup = get_language_selection_keyboard(
|
||||
self.i18n,
|
||||
"en",
|
||||
back_callback="main_action:bot_interface",
|
||||
)
|
||||
|
||||
self.assertIn("main_action:bot_interface", self._callback_data(subscription_markup))
|
||||
self.assertIn("main_action:bot_interface", self._callback_data(referral_markup))
|
||||
self.assertIn("main_action:bot_interface", self._callback_data(info_markup))
|
||||
self.assertIn("set_lang_ru:bot", self._callback_data(language_markup))
|
||||
|
||||
def test_webapp_referral_link_uses_ref_query_and_is_normalized(self):
|
||||
link = referral._build_webapp_referral_link(
|
||||
"https://app.example.com/invite?utm=channel",
|
||||
"AbC123xYz",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
link,
|
||||
"https://app.example.com/invite?utm=channel&ref=uAbC123xYz",
|
||||
)
|
||||
self.assertEqual(subscription_webapp._normalize_referral_param("uAbC123xYz"), "ABC123XYZ")
|
||||
self.assertEqual(subscription_webapp._normalize_referral_param("ref_uAbC123xYz"), "ABC123XYZ")
|
||||
|
||||
def test_referral_text_places_web_link_after_telegram_link(self):
|
||||
text = self.i18n.gettext(
|
||||
"en",
|
||||
"referral_program_info_new",
|
||||
referral_link="https://t.me/bot?start=ref_uABC123XYZ",
|
||||
webapp_link_section=self.i18n.gettext(
|
||||
"en",
|
||||
"referral_webapp_link_line",
|
||||
webapp_referral_link="https://app.example.com/?ref=uABC123XYZ",
|
||||
),
|
||||
bonus_details="bonus",
|
||||
invited_count=1,
|
||||
purchased_count=0,
|
||||
)
|
||||
|
||||
self.assertLess(text.index("Telegram link:"), text.index("Web link:"))
|
||||
self.assertLess(text.index("Web link:"), text.index("Invitation bonuses:"))
|
||||
@@ -6,9 +6,27 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot.app.web import subscription_webapp
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TRAFFIC_PACKAGES="10:199,50:799",
|
||||
STARS_TRAFFIC_PACKAGES="50:2500",
|
||||
)
|
||||
|
||||
plans = subscription_webapp._serialize_plans(settings, "en")
|
||||
|
||||
self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0])
|
||||
self.assertEqual(plans[0]["sale_mode"], "traffic")
|
||||
self.assertEqual(plans[0]["price"], 199.0)
|
||||
self.assertEqual(plans[1]["stars_price"], 2500)
|
||||
|
||||
def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asset_dir = Path(tmpdir)
|
||||
|
||||
Reference in New Issue
Block a user