From 259d0646bc24d018c60cd3fb1aa5216c969ea063 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Wed, 22 Apr 2026 22:35:10 +0300
Subject: [PATCH] fix: tune webapp visual and fix some errors
---
.dockerignore | 1 +
.env.example | 2 +-
README.md | 6 +-
.../web/templates/subscription_webapp.html | 857 +++++++++++++-----
bot/handlers/user/promo_user.py | 21 +-
bot/handlers/user/start.py | 91 +-
bot/middlewares/db_session.py | 15 +
bot/utils/callback_answer.py | 52 ++
config/settings.py | 2 +-
9 files changed, 770 insertions(+), 277 deletions(-)
create mode 100644 bot/utils/callback_answer.py
diff --git a/.dockerignore b/.dockerignore
index 47cfd69..7662200 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -17,6 +17,7 @@ docker-compose.yml
Dockerfile
.docker
.dockerignore
+tmp/
# Byte-compiled / optimized / DLL files
**/__pycache__/
diff --git a/.env.example b/.env.example
index 68b28bc..0d9320a 100644
--- a/.env.example
+++ b/.env.example
@@ -37,7 +37,7 @@ WEBAPP_ENABLED=True #
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_PRIMARY_COLOR="#10b981" # Main UI color
+WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL
WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
diff --git a/README.md b/README.md
index daf7ee1..58b76c7 100644
--- a/README.md
+++ b/README.md
@@ -71,7 +71,7 @@
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
-| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#10b981` |
+| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
@@ -202,7 +202,7 @@ Web App запускается в том же контейнере, что и б
WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка"
- WEBAPP_PRIMARY_COLOR="#10b981"
+ WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL=
```
@@ -248,7 +248,7 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build
```
-После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени, трафик, тарифы и создает платежные ссылки через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
+После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html
index 1b982d1..7fa2019 100644
--- a/bot/app/web/templates/subscription_webapp.html
+++ b/bot/app/web/templates/subscription_webapp.html
@@ -5,51 +5,62 @@
-
+
Моя подписка
+
+
+
@@ -381,31 +620,36 @@
-
-
-
+
+
-
+
+
...
+
Ваша ссылка подключения и срок доступа
+
+
+
-
Окончание
+
ends_at
...
@@ -414,29 +658,68 @@
+
+
-
Оплата подписки
-
+
+
+
+
Оплата подписки
+
Выберите срок, способ оплаты и создайте платеж.
+
+
+
-
- Способ оплаты
-
+
+
+ 01
+ Срок
+
+
+ 02
+ Метод
+
+
+ 03
+ Платеж
+
+
+
+
+
select_period
+
+
+
+
+
+
select_method
+
+
+
+
+
+
+
+
+
payment_status
+
+
+
+
+
+
+
-
-
-
-
-
-
- Поддержка
+ Поддержка
+
telegram_auth
Вход через Telegram
-
Откройте бота и подтвердите вход. После подтверждения эта страница обновится автоматически.
+
Откройте бота и подтвердите вход. После подтверждения страница обновится автоматически.
Открыть Telegram
@@ -449,12 +732,16 @@
token: localStorage.getItem('rw_webapp_token') || '',
data: null,
selectedPlan: null,
+ selectedMethod: null,
payment: null,
+ paymentFlowOpen: false,
+ paymentStep: 'plan',
+ creatingPayment: false,
authPoll: null
};
- document.documentElement.classList.toggle('theme-dark', !tg || tg.colorScheme !== 'light');
- document.documentElement.style.setProperty('--accent', CFG.primaryColor || '#10b981');
+ const accent = CFG.primaryColor || '#00fe7a';
+ document.documentElement.style.setProperty('--accent', accent);
document.title = CFG.title || 'Моя подписка';
document.getElementById('brand-title').textContent = CFG.title || 'Моя подписка';
if (CFG.logoUrl) {
@@ -470,7 +757,8 @@
try {
tg.ready();
tg.expand();
- tg.setHeaderColor(CFG.primaryColor || '#10b981');
+ tg.setHeaderColor('#05070a');
+ tg.setBackgroundColor('#05070a');
} catch (e) { }
}
@@ -531,10 +819,15 @@
}
async function loadData() {
+ const previousMonths = state.selectedPlan && state.selectedPlan.months;
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
- state.selectedPlan = data.plans && data.plans.length ? data.plans[0] : null;
+ const plans = data.plans || [];
+ state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
+ state.selectedMethod = null;
+ state.payment = null;
+ state.paymentStep = 'plan';
render();
showApp();
}
@@ -546,13 +839,12 @@
function render() {
renderSubscription(state.data.subscription);
- renderPlans(state.data.plans || []);
- renderMethods();
+ renderPaymentFlow();
}
function renderSubscription(sub) {
const badge = document.getElementById('status-badge');
- badge.textContent = sub.active ? 'Активна' : 'Не активна';
+ badge.textContent = sub.active ? 'ACTIVE' : 'INACTIVE';
badge.classList.toggle('off', !sub.active);
document.getElementById('remaining').textContent = sub.remaining_text || 'Нет активной подписки';
document.getElementById('end-date').textContent = sub.end_date_text || 'N/A';
@@ -560,103 +852,201 @@
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
+ function togglePaymentFlow() {
+ if (state.paymentFlowOpen) {
+ closePaymentFlow();
+ return;
+ }
+ state.paymentFlowOpen = true;
+ state.paymentStep = 'plan';
+ state.payment = null;
+ state.selectedMethod = null;
+ renderPaymentFlow();
+ document.getElementById('payment-flow').scrollIntoView({behavior: 'smooth', block: 'start'});
+ }
+
+ function closePaymentFlow() {
+ state.paymentFlowOpen = false;
+ state.paymentStep = 'plan';
+ state.payment = null;
+ state.selectedMethod = null;
+ renderPaymentFlow();
+ }
+
+ function renderPaymentFlow() {
+ const flow = document.getElementById('payment-flow');
+ flow.classList.toggle('hidden', !state.paymentFlowOpen);
+ if (!state.paymentFlowOpen) return;
+
+ renderStepState();
+ renderPlans(state.data.plans || []);
+ renderMethods();
+ renderPaymentResult();
+ }
+
+ function renderStepState() {
+ const order = ['plan', 'method', 'result'];
+ order.forEach((step, index) => {
+ const node = document.getElementById('step-' + step);
+ const currentIndex = order.indexOf(state.paymentStep);
+ node.classList.toggle('active', state.paymentStep === step);
+ node.classList.toggle('done', currentIndex > index);
+ });
+
+ document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan');
+ document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method');
+ document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result');
+ }
+
function renderPlans(plans) {
const wrap = document.getElementById('plans');
+ const nextBtn = document.getElementById('to-methods-btn');
if (!plans.length) {
wrap.innerHTML = 'Тарифы не настроены.
';
- document.getElementById('methods-wrap').classList.add('hidden');
+ nextBtn.disabled = true;
return;
}
+ nextBtn.disabled = !state.selectedPlan;
wrap.innerHTML = plans.map(plan => {
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
- const stars = plan.stars_price ? ' · ' + plan.stars_price + ' ⭐' : '';
+ const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
return `
`;
}).join('');
}
function renderMethods() {
- const wrap = document.getElementById('methods-wrap');
const methods = document.getElementById('methods');
+ const createBtn = document.getElementById('create-payment-btn');
if (!state.selectedPlan) {
- wrap.classList.add('hidden');
+ methods.innerHTML = 'Сначала выберите срок подписки.
';
+ createBtn.disabled = true;
return;
}
- const available = (state.data.payment_methods || []).filter(method => {
- if (method.id === 'stars') return Number.isFinite(Number(state.selectedPlan.stars_price));
- return true;
- });
- wrap.classList.remove('hidden');
+
+ const available = getAvailableMethods();
+ if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) {
+ state.selectedMethod = null;
+ }
+
+ createBtn.disabled = !state.selectedMethod || state.creatingPayment;
if (!available.length) {
methods.innerHTML = 'Нет доступных способов оплаты.
';
+ createBtn.disabled = true;
return;
}
+
methods.innerHTML = available.map(method => {
+ const isActive = state.selectedMethod === method.id;
const amount = method.id === 'stars'
- ? state.selectedPlan.stars_price + ' ⭐'
+ ? state.selectedPlan.stars_price + ' Stars'
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
return `
-