From ccb442fe44dc44ff4622228f871adb8f42e36311 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:10:06 +0300 Subject: [PATCH 01/40] Create index.html --- miniapp/index.html | 724 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 miniapp/index.html diff --git a/miniapp/index.html b/miniapp/index.html new file mode 100644 index 00000000..5ea8ab7f --- /dev/null +++ b/miniapp/index.html @@ -0,0 +1,724 @@ + + + + + + + VPN Subscription + + + + +
+ +
+ +
Secure & Fast Connection
+
+ + +
+
+
Loading your subscription...
+
+ + + + + + +
+ + + + From f919368d0b35c22798cbe31bc5eb44d9b496128e Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:32:36 +0300 Subject: [PATCH 02/40] Document miniapp deployment and reverse proxy setup --- README.md | 4 + app/utils/telegram_webapp.py | 91 ++++++++ app/webapi/app.py | 6 + app/webapi/routes/__init__.py | 2 + app/webapi/routes/miniapp.py | 213 +++++++++++++++++ app/webapi/schemas/miniapp.py | 47 ++++ docs/miniapp-setup.md | 136 +++++++++++ miniapp/index.html | 414 ++++++++++++++++++++++++++-------- 8 files changed, 818 insertions(+), 95 deletions(-) create mode 100644 app/utils/telegram_webapp.py create mode 100644 app/webapi/routes/miniapp.py create mode 100644 app/webapi/schemas/miniapp.py create mode 100644 docs/miniapp-setup.md diff --git a/README.md b/README.md index 189ad150..bbddbaa9 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ docker compose logs Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md). +### 📱 Telegram Mini App с подпиской + +Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md). + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | diff --git a/app/utils/telegram_webapp.py b/app/utils/telegram_webapp.py new file mode 100644 index 00000000..43d74d5c --- /dev/null +++ b/app/utils/telegram_webapp.py @@ -0,0 +1,91 @@ +"""Utilities for validating Telegram WebApp initialization data.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from typing import Any, Dict +from urllib.parse import parse_qsl + + +class TelegramWebAppAuthError(Exception): + """Raised when Telegram WebApp init data fails validation.""" + + +def parse_webapp_init_data( + init_data: str, + bot_token: str, + *, + max_age_seconds: int = 86400, +) -> Dict[str, Any]: + """Validate and parse Telegram WebApp init data. + + Args: + init_data: Raw init data string provided by Telegram WebApp. + bot_token: Bot token used to verify the signature. + max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours. + + Returns: + Parsed init data as a dictionary. + + Raises: + TelegramWebAppAuthError: If validation fails. + """ + + if not init_data: + raise TelegramWebAppAuthError("Missing init data") + + if not bot_token: + raise TelegramWebAppAuthError("Bot token is not configured") + + parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True) + data: Dict[str, Any] = {key: value for key, value in parsed_pairs} + + received_hash = data.pop("hash", None) + if not received_hash: + raise TelegramWebAppAuthError("Missing init data signature") + + data_check_string = "\n".join( + f"{key}={value}" for key, value in sorted(data.items()) + ) + + secret_key = hmac.new( + key=b"WebAppData", + msg=bot_token.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + + computed_hash = hmac.new( + key=secret_key, + msg=data_check_string.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(computed_hash, received_hash): + raise TelegramWebAppAuthError("Invalid init data signature") + + auth_date_raw = data.get("auth_date") + if auth_date_raw is not None: + try: + auth_date = int(auth_date_raw) + except (TypeError, ValueError): + raise TelegramWebAppAuthError("Invalid auth_date value") from None + + if max_age_seconds and auth_date: + current_ts = int(time.time()) + if current_ts - auth_date > max_age_seconds: + raise TelegramWebAppAuthError("Init data is too old") + + data["auth_date"] = auth_date + + user_payload = data.get("user") + if user_payload is not None: + try: + data["user"] = json.loads(user_payload) + except json.JSONDecodeError as error: + raise TelegramWebAppAuthError("Invalid user payload") from error + + return data + diff --git a/app/webapi/app.py b/app/webapi/app.py index 0761ecad..06a10569 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,6 +13,7 @@ from .routes import ( config, health, promocodes, + miniapp, promo_groups, remnawave, stats, @@ -68,6 +69,10 @@ OPENAPI_TAGS = [ "данных между ботом и панелью." ), }, + { + "name": "miniapp", + "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", + }, ] @@ -110,5 +115,6 @@ def create_web_api_app() -> FastAPI: app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"]) app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) + app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 31aef685..22ed34ae 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -1,6 +1,7 @@ from . import ( config, health, + miniapp, promo_groups, remnawave, stats, @@ -14,6 +15,7 @@ from . import ( __all__ = [ "config", "health", + "miniapp", "promo_groups", "remnawave", "stats", diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py new file mode 100644 index 00000000..5c4d596c --- /dev/null +++ b/app/webapi/routes/miniapp.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import Subscription +from app.services.subscription_service import SubscriptionService +from app.utils.telegram_webapp import ( + TelegramWebAppAuthError, + parse_webapp_init_data, +) + +from ..dependencies import get_db_session +from ..schemas.miniapp import ( + MiniAppSubscriptionRequest, + MiniAppSubscriptionResponse, + MiniAppSubscriptionUser, +) + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _format_gb(value: Optional[float]) -> float: + if value is None: + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _format_gb_label(value: float) -> str: + absolute = abs(value) + if absolute >= 100: + return f"{value:.0f} GB" + if absolute >= 10: + return f"{value:.1f} GB" + return f"{value:.2f} GB" + + +def _format_limit_label(limit: Optional[int]) -> str: + if not limit: + return "Unlimited" + return f"{limit} GB" + + +def _bytes_to_gb(bytes_value: Optional[int]) -> float: + if not bytes_value: + return 0.0 + return round(bytes_value / (1024 ** 3), 2) + + +def _status_label(status: str) -> str: + mapping = { + "active": "Active", + "trial": "Trial", + "expired": "Expired", + "disabled": "Disabled", + } + return mapping.get(status, status.title()) + + +def _resolve_display_name(user_data: Dict[str, Any]) -> str: + username = user_data.get("username") + if username: + return username + + first = user_data.get("first_name") + last = user_data.get("last_name") + parts = [part for part in [first, last] if part] + if parts: + return " ".join(parts) + + telegram_id = user_data.get("telegram_id") + return f"User {telegram_id}" if telegram_id else "User" + + +def _is_remnawave_configured() -> bool: + params = settings.get_remnawave_auth_params() + return bool(params.get("base_url") and params.get("api_key")) + + +async def _load_subscription_links( + subscription: Subscription, +) -> Dict[str, Any]: + if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): + return {} + + try: + service = SubscriptionService() + info = await service.get_subscription_info(subscription.remnawave_short_uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load subscription info from RemnaWave: %s", error) + return {} + + if not info: + return {} + + payload: Dict[str, Any] = { + "links": list(info.links or []), + "ss_conf_links": dict(info.ss_conf_links or {}), + "subscription_url": info.subscription_url, + "happ": info.happ, + "happ_link": getattr(info, "happ_link", None), + "happ_crypto_link": getattr(info, "happ_crypto_link", None), + } + + return payload + + +@router.post("/subscription", response_model=MiniAppSubscriptionResponse) +async def get_subscription_details( + payload: MiniAppSubscriptionRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(error), + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + if not user or not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription not found", + ) + + subscription = user.subscription + traffic_used = _format_gb(subscription.traffic_used_gb) + traffic_limit = subscription.traffic_limit_gb or 0 + lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) + + status_actual = subscription.actual_status + links_payload = await _load_subscription_links(subscription) + + subscription_url = links_payload.get("subscription_url") or subscription.subscription_url + subscription_crypto_link = ( + links_payload.get("happ_crypto_link") + or subscription.subscription_crypto_link + ) + + connected_squads: List[str] = list(subscription.connected_squads or []) + links: List[str] = links_payload.get("links") or connected_squads + ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} + + response_user = MiniAppSubscriptionUser( + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + display_name=_resolve_display_name( + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "telegram_id": user.telegram_id, + } + ), + language=user.language, + status=user.status, + subscription_status=subscription.status, + subscription_actual_status=status_actual, + status_label=_status_label(status_actual), + expires_at=subscription.end_date, + device_limit=subscription.device_limit, + traffic_used_gb=round(traffic_used, 2), + traffic_used_label=_format_gb_label(traffic_used), + traffic_limit_gb=traffic_limit, + traffic_limit_label=_format_limit_label(traffic_limit), + lifetime_used_traffic_gb=lifetime_used, + has_active_subscription=status_actual in {"active", "trial"}, + ) + + return MiniAppSubscriptionResponse( + subscription_id=subscription.id, + remnawave_short_uuid=subscription.remnawave_short_uuid, + user=response_user, + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link, + links=links, + ss_conf_links=ss_conf_links, + connected_squads=connected_squads, + happ=links_payload.get("happ"), + happ_link=links_payload.get("happ_link"), + happ_crypto_link=links_payload.get("happ_crypto_link"), + ) + diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py new file mode 100644 index 00000000..9c5a4308 --- /dev/null +++ b/app/webapi/schemas/miniapp.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MiniAppSubscriptionRequest(BaseModel): + init_data: str = Field(..., alias="initData") + + +class MiniAppSubscriptionUser(BaseModel): + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: str + language: Optional[str] = None + status: str + subscription_status: str + subscription_actual_status: str + status_label: str + expires_at: Optional[datetime] = None + device_limit: Optional[int] = None + traffic_used_gb: float = 0.0 + traffic_used_label: str + traffic_limit_gb: Optional[int] = None + traffic_limit_label: str + lifetime_used_traffic_gb: float = 0.0 + has_active_subscription: bool = False + + +class MiniAppSubscriptionResponse(BaseModel): + success: bool = True + subscription_id: int + remnawave_short_uuid: Optional[str] = None + user: MiniAppSubscriptionUser + subscription_url: Optional[str] = None + subscription_crypto_link: Optional[str] = None + links: List[str] = Field(default_factory=list) + ss_conf_links: Dict[str, str] = Field(default_factory=dict) + connected_squads: List[str] = Field(default_factory=list) + happ: Optional[Dict[str, Any]] = None + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None + diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md new file mode 100644 index 00000000..2ab7d0e0 --- /dev/null +++ b/docs/miniapp-setup.md @@ -0,0 +1,136 @@ +# Настройка мини-приложения Telegram + +Эта инструкция описывает, как запустить статическую страницу из каталога `miniapp/index.html`, подключить её к административному API бота и опубликовать через reverse-proxy (nginx или Caddy). Страница отображает текущую подписку пользователя и использует Telegram WebApp init data для авторизации. + +## 1. Требования + +- Развёрнутый бот Bedolaga c актуальной базой данных. +- Включённое административное API (`WEB_API_ENABLED=true`). +- Доменное имя с действующим TLS-сертификатом (Telegram открывает веб-приложения только по HTTPS). +- Возможность разместить статические файлы (`miniapp/index.html` и `app-config.json`) и проксировать запросы `/miniapp/*` к боту. + +## 2. Настройка окружения + +1. Скопируйте пример конфигурации и включите веб-API: + ```bash + cp .env.example .env + nano .env + ``` +2. Задайте как минимум следующие переменные: + ```env + BOT_TOKEN=123456:ABCDEF # токен вашего бота + WEB_API_ENABLED=true # включает FastAPI + WEB_API_HOST=0.0.0.0 + WEB_API_PORT=8080 + WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com + WEB_API_DEFAULT_TOKEN=super-secret-token + SERVER_STATUS_MODE=external_link_miniapp + SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com + ``` + - `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение. + - `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`. +3. Если используете RemnaWave, убедитесь, что заданы `REMNAWAVE_API_URL` и `REMNAWAVE_API_KEY`, чтобы в мини-приложении отображались дополнительные ссылки подписки. + +## 3. Запуск административного API + +API можно запускать вместе с ботом (`python main.py`) или отдельно для тестов: +```bash +uvicorn app.webapi.app:create_web_api_app --host 0.0.0.0 --port 8080 +``` +После старта проверьте доступность: +```bash +curl -H "X-API-Key: super-secret-token" https://miniapp.example.com/miniapp/health || \ +curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health +``` + +## 4. Подготовка статических файлов + +1. Скопируйте `miniapp/index.html` и `app-config.json` на сервер, из которого nginx/Caddy будет отдавать статический контент. Например: + ```bash + sudo mkdir -p /var/www/remnawave-miniapp + sudo cp miniapp/index.html /var/www/remnawave-miniapp/ + sudo cp app-config.json /var/www/remnawave-miniapp/ + ``` +2. При необходимости отредактируйте `app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты. +3. Убедитесь, что файлы доступны для чтения пользователем веб-сервера. + +## 5. Настройка кнопки в Telegram + +1. В `.env` уже выставлен `SERVER_STATUS_MODE=external_link_miniapp` и `SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com`. +2. Перезапустите бота. В главном меню появится кнопка «Статус серверов», открывающая веб-приложение внутри Telegram. +3. При необходимости задайте кастомную кнопку через `@BotFather` (команда `/setmenu` -> Web App URL). + +## 6. Конфигурация nginx + +```nginx +server { + listen 80; + listen 443 ssl http2; + server_name miniapp.example.com; + + ssl_certificate /etc/letsencrypt/live/miniapp.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem; + + # Статические файлы мини-приложения + root /var/www/remnawave-miniapp; + index index.html; + + location = /app-config.json { + add_header Access-Control-Allow-Origin "*"; + try_files $uri =404; + } + + location / { + try_files $uri /index.html =404; + } + + # Проксирование запросов к административному API + location /miniapp/ { + proxy_pass http://127.0.0.1:8080/miniapp/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Если нужно проксировать другие эндпоинты API, добавьте аналогичные location-блоки. +} +``` + +## 7. Конфигурация Caddy + +```caddy +miniapp.example.com { + encode gzip zstd + root * /var/www/remnawave-miniapp + file_server + + @config path /app-config.json + header @config Access-Control-Allow-Origin "*" + + reverse_proxy /miniapp/* 127.0.0.1:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} +``` +Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер. + +## 8. Проверка работы + +1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`. +2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки. +3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок. + +## 9. Диагностика + +| Симптом | Возможная причина | Проверка | +|---------|------------------|----------| +| Белый экран и ошибка 401 | Неверный `X-API-Key` или `WEB_API_ALLOWED_ORIGINS`. | Проверьте токен и заголовки запроса, перегенерируйте токен через `/tokens`. | +| Ошибка 404 на `/miniapp/subscription` | Прокси не пробрасывает запросы или API не запущено. | Проверьте лог nginx/Caddy и убедитесь, что бот запущен с `WEB_API_ENABLED=true`. | +| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. | +| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. | + +После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую. diff --git a/miniapp/index.html b/miniapp/index.html index 5ea8ab7f..ba983c31 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -158,6 +158,21 @@ color: #842029; } + .status-trial { + background: #fff3cd; + color: #664d03; + } + + .status-disabled { + background: #e2e3e5; + color: #41464b; + } + + .status-unknown { + background: #e7eaf3; + color: #495057; + } + /* Stats Grid */ .stats-grid { display: grid; @@ -242,6 +257,12 @@ border: 1px solid var(--border-color); } + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + } + .btn-icon { width: 20px; height: 20px; @@ -427,8 +448,8 @@ @@ -504,92 +525,160 @@ From 078f1f8878d90fc55f61f8e5bf954d5d015a597d Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:05:34 +0300 Subject: [PATCH 03/40] Add localization and account info to mini app --- app/webapi/routes/miniapp.py | 31 +- app/webapi/schemas/miniapp.py | 19 + miniapp/index.html | 760 +++++++++++++++++++++++++++++----- 3 files changed, 712 insertions(+), 98 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5c4d596c..136c6864 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3,12 +3,13 @@ from __future__ import annotations import logging from typing import Any, Dict, List, Optional +from sqlalchemy import select from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription +from app.database.models import Subscription, Transaction from app.services.subscription_service import SubscriptionService from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -195,8 +196,32 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, + balance_kopeks=user.balance_kopeks, + balance_rubles=round((user.balance_kopeks or 0) / 100, 2), ) + transactions_result = await db.execute( + select(Transaction) + .where(Transaction.user_id == user.id) + .order_by(Transaction.created_at.desc()) + .limit(20) + ) + transactions = transactions_result.scalars().all() + + serialized_transactions = [ + { + "id": transaction.id, + "type": transaction.type, + "amount_kopeks": transaction.amount_kopeks, + "amount_rubles": round(transaction.amount_kopeks / 100, 2), + "description": transaction.description, + "is_completed": transaction.is_completed, + "created_at": transaction.created_at, + "payment_method": transaction.payment_method, + } + for transaction in transactions + ] + return MiniAppSubscriptionResponse( subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, @@ -209,5 +234,9 @@ async def get_subscription_details( happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), + happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(), + transactions=serialized_transactions, + balance_kopeks=user.balance_kopeks, + balance_rubles=round((user.balance_kopeks or 0) / 100, 2), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 9c5a4308..911202fb 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -29,6 +29,19 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + + +class MiniAppTransaction(BaseModel): + id: int + type: str + amount_kopeks: int + amount_rubles: float + description: Optional[str] = None + is_completed: bool = True + created_at: datetime + payment_method: Optional[str] = None class MiniAppSubscriptionResponse(BaseModel): @@ -44,4 +57,10 @@ class MiniAppSubscriptionResponse(BaseModel): happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None + happ_cryptolink_redirect_template: Optional[str] = None + transactions: List[MiniAppTransaction] = Field(default_factory=list) + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + + diff --git a/miniapp/index.html b/miniapp/index.html index ba983c31..4a4dadaa 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -22,7 +22,7 @@ --tg-theme-button-color: #2481cc; --tg-theme-button-text-color: #ffffff; --tg-theme-secondary-bg-color: #f0f0f0; - + --primary: var(--tg-theme-button-color); --text-primary: var(--tg-theme-text-color); --text-secondary: var(--tg-theme-hint-color); @@ -44,16 +44,25 @@ } .container { - max-width: 480px; + max-width: 520px; margin: 0 auto; } /* Header */ .header { - text-align: center; + display: flex; + flex-direction: column; + gap: 12px; margin-bottom: 24px; } + .header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + } + .logo { font-size: 28px; font-weight: 700; @@ -66,6 +75,36 @@ color: var(--text-secondary); } + .language-selector { + display: inline-flex; + background: var(--bg-secondary); + border-radius: 999px; + padding: 4px; + gap: 4px; + } + + .lang-btn { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s ease; + } + + .lang-btn.active { + background: var(--primary); + color: #ffffff; + box-shadow: 0 2px 6px rgba(36, 129, 204, 0.35); + } + + .lang-btn:active { + transform: scale(0.96); + } + /* Loading State */ .loading { text-align: center; @@ -225,6 +264,101 @@ text-align: right; } + /* Balance */ + .balance-amount { + font-size: 28px; + font-weight: 700; + color: var(--primary); + } + + .balance-hint { + margin-top: 6px; + font-size: 13px; + color: var(--text-secondary); + } + + /* History */ + .history-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .history-item { + display: flex; + justify-content: space-between; + align-items: center; + background: white; + border-radius: 10px; + padding: 12px; + } + + .history-item__info { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + } + + .history-item__title { + font-weight: 600; + color: var(--text-primary); + } + + .history-item__date { + color: var(--text-secondary); + font-size: 12px; + } + + .history-item__amount { + font-weight: 700; + font-size: 14px; + } + + .history-item__amount.positive { + color: #0f5132; + } + + .history-item__amount.negative { + color: #842029; + } + + .empty-placeholder { + font-size: 13px; + color: var(--text-secondary); + background: white; + border-radius: 10px; + padding: 16px; + text-align: center; + } + + /* Servers */ + .server-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .server-item { + background: white; + border-radius: 10px; + padding: 12px; + font-size: 13px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--text-primary); + } + + .server-item__name { + font-weight: 600; + } + + .server-item__status { + color: var(--text-secondary); + font-size: 12px; + } + /* Buttons */ .btn { width: 100%; @@ -435,21 +569,29 @@
- -
Secure & Fast Connection
+
+
+ +
Secure & Fast Connection
+
+
+ + +
+
-
Loading your subscription...
+
Loading your subscription...
@@ -467,48 +609,67 @@
-
-
Days Left
+
Days Left
-
-
Servers
+
Servers
- Expires + Expires -
- Traffic Used + Traffic Used -
- Traffic Limit + Traffic Limit -
+ +
+
Balance
+
+
Available funds for subscription payments
+
+ + +
+
Connected Servers
+
+
+ + +
+
Latest Transactions
+
+
+
-
Installation Guide
- +
Installation Guide
+
@@ -547,10 +708,224 @@ }); } + const fallbackLanguage = 'en'; + const supportedLanguages = ['en', 'ru']; + + const translations = { + en: { + header: { + title: 'RemnaWave VPN', + subtitle: 'Secure & Fast Connection', + }, + loading: { + message: 'Loading your subscription...' + }, + errors: { + defaultTitle: 'Subscription Not Found', + defaultText: 'Please contact support to activate your subscription', + authTitle: 'Authorization Error', + authText: 'Authorization failed. Please open the mini app from Telegram.' + }, + stats: { + daysLeft: 'Days Left', + servers: 'Servers', + }, + info: { + expires: 'Expires', + trafficUsed: 'Traffic Used', + trafficLimit: 'Traffic Limit', + }, + balance: { + title: 'Balance', + hint: 'Available funds for subscription payments' + }, + servers: { + title: 'Connected Servers', + empty: 'No connected servers yet' + }, + history: { + title: 'Latest Transactions', + empty: 'There are no transactions yet' + }, + buttons: { + connect: 'Connect to VPN', + copy: 'Copy Subscription Link' + }, + apps: { + title: 'Installation Guide', + download: 'Download & Install', + open: 'Open the App', + import: 'Import Subscription', + manual: 'Configure Manually', + copy: 'Copy Link', + instructions: 'Follow the steps below to finish the setup', + empty: 'No installation guide available for this platform yet.', + recommended: 'Recommended' + }, + status: { + active: 'Active', + expired: 'Expired', + trial: 'Trial', + disabled: 'Disabled', + unknown: 'Unknown' + }, + popup: { + copiedTitle: 'Copied', + copiedText: 'Subscription link copied to clipboard', + copyFailedTitle: 'Copy failed', + copyFailedText: 'Unable to copy the subscription link automatically. Please copy it manually.' + }, + traffic: { + unlimited: 'Unlimited' + } + }, + ru: { + header: { + title: 'RemnaWave VPN', + subtitle: 'Безопасное и быстрое подключение', + }, + loading: { + message: 'Загружаем данные подписки...' + }, + errors: { + defaultTitle: 'Подписка не найдена', + defaultText: 'Свяжитесь с поддержкой для активации подписки', + authTitle: 'Ошибка авторизации', + authText: 'Не удалось авторизоваться. Откройте мини-приложение из Telegram.' + }, + stats: { + daysLeft: 'Дней осталось', + servers: 'Серверы' + }, + info: { + expires: 'Истекает', + trafficUsed: 'Израсходовано трафика', + trafficLimit: 'Лимит трафика' + }, + balance: { + title: 'Баланс', + hint: 'Доступные средства для оплаты подписки' + }, + servers: { + title: 'Подключенные серверы', + empty: 'Подключенных серверов пока нет' + }, + history: { + title: 'История операций', + empty: 'Операций пока нет' + }, + buttons: { + connect: 'Подключиться', + copy: 'Скопировать ссылку подписки' + }, + apps: { + title: 'Инструкция по установке', + download: 'Скачайте и установите', + open: 'Откройте приложение', + import: 'Импортируйте подписку', + manual: 'Настройте вручную', + copy: 'Скопировать ссылку', + instructions: 'Следуйте шагам, чтобы завершить настройку', + empty: 'Инструкция для этой платформы пока недоступна.', + recommended: 'Рекомендуем' + }, + status: { + active: 'Активна', + expired: 'Истекла', + trial: 'Пробная', + disabled: 'Выключена', + unknown: 'Неизвестно' + }, + popup: { + copiedTitle: 'Скопировано', + copiedText: 'Ссылка на подписку скопирована', + copyFailedTitle: 'Не удалось скопировать', + copyFailedText: 'Не удалось скопировать ссылку автоматически. Скопируйте её вручную.' + }, + traffic: { + unlimited: 'Безлимит' + } + } + }; + let userData = null; let appsConfig = {}; let currentPlatform = 'android'; - let preferredLanguage = 'en'; + let preferredLanguage = fallbackLanguage; + let redirectTemplate = null; + let redirectTarget = null; + + function getLocale() { + const mapping = { + ru: 'ru-RU', + en: 'en-US' + }; + return mapping[preferredLanguage] || mapping.en; + } + + function translate(key, fallback = '') { + const langPack = translations[preferredLanguage] || translations[fallbackLanguage]; + if (!key || typeof key !== 'string') { + return fallback; + } + + const parts = key.split('.'); + let value = langPack; + + for (const part of parts) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + value = null; + break; + } + } + + if (typeof value === 'string') { + return value; + } + + return fallback; + } + + function translateStatus(status) { + if (!status) { + return translate('status.unknown', 'Unknown'); + } + const normalized = status.toLowerCase(); + return translate(`status.${normalized}`, status.charAt(0).toUpperCase() + status.slice(1)); + } + + function applyTranslations() { + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.dataset.i18n; + const translated = translate(key, element.textContent); + if (translated) { + element.textContent = translated; + } + }); + } + + function setLanguage(language, options = {}) { + const { skipRender = false } = options; + if (!supportedLanguages.includes(language)) { + language = fallbackLanguage; + } + preferredLanguage = language; + + document.querySelectorAll('.lang-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.lang === preferredLanguage); + }); + + applyTranslations(); + + if (!skipRender) { + renderUserData(); + renderTransactionHistory(); + renderConnectedServers(); + renderApps(); + } + } function createError(title, message, status) { const error = new Error(message || title); @@ -569,12 +944,17 @@ if (telegramUser?.language_code) { preferredLanguage = telegramUser.language_code.split('-')[0]; } + setLanguage(preferredLanguage, { skipRender: true }); await loadAppsConfig(); const initData = tg.initData || ''; if (!initData) { - throw createError('Authorization Error', 'Missing Telegram init data'); + throw createError( + translate('errors.authTitle', 'Authorization Error'), + translate('errors.authText', 'Missing Telegram init data'), + 401 + ); } const response = await fetch('/miniapp/subscription', { @@ -586,10 +966,12 @@ }); if (!response.ok) { + let title = response.status === 401 + ? translate('errors.authTitle', 'Authorization Error') + : translate('errors.defaultTitle', 'Subscription Not Found'); let detail = response.status === 401 - ? 'Authorization failed. Please open the mini app from Telegram.' - : 'Subscription not found'; - let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; + ? translate('errors.authText', 'Authorization failed. Please open the mini app from Telegram.') + : translate('errors.defaultText', 'Subscription not found'); try { const errorPayload = await response.json(); @@ -606,15 +988,14 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + redirectTemplate = userData.happ_cryptolink_redirect_template || null; + redirectTarget = userData.happ_crypto_link || userData.subscriptionCryptoLink || null; if (userData?.user?.language) { preferredLanguage = userData.user.language; } - renderUserData(); - detectPlatform(); - setActivePlatformButton(); - renderApps(); + setLanguage(preferredLanguage); document.getElementById('loadingState').classList.add('hidden'); document.getElementById('mainContent').classList.remove('hidden'); @@ -656,7 +1037,7 @@ const knownStatuses = ['active', 'expired', 'trial', 'disabled']; const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown'; const statusBadge = document.getElementById('statusBadge'); - statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); + statusBadge.textContent = user.status_label || translateStatus(statusClass); statusBadge.className = `status-badge status-${statusClass}`; const expiresAt = user.expires_at ? new Date(user.expires_at) : null; @@ -667,7 +1048,11 @@ } document.getElementById('daysLeft').textContent = daysLeft; document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime()) - ? expiresAt.toLocaleDateString() + ? expiresAt.toLocaleDateString(getLocale(), { + year: 'numeric', + month: 'long', + day: 'numeric' + }) : '—'; const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0; @@ -678,6 +1063,7 @@ document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + renderBalance(); updateActionButtons(); } @@ -723,13 +1109,13 @@ const apps = getAppsForCurrentPlatform(); if (!apps.length) { - container.innerHTML = '
No installation guide available for this platform yet.
'; + container.innerHTML = `
${translate('apps.empty', 'No installation guide available for this platform yet.')}
`; return; } container.innerHTML = apps.map(app => { const iconChar = (app.name?.[0] || 'A').toUpperCase(); - const featuredBadge = app.isFeatured ? 'Recommended' : ''; + const featuredBadge = app.isFeatured ? `${translate('apps.recommended', 'Recommended')}` : ''; return `
@@ -756,42 +1142,49 @@
${stepNum++} - Download & Install + ${translate('apps.download', 'Download & Install')}
- ${app.installationStep.description ? `
${getLocalizedText(app.installationStep.description)}
` : ''} - ${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? ` -
- ${app.installationStep.buttons.map(btn => ` - - ${getLocalizedText(btn.buttonText)} - - `).join('')} -
- ` : ''} +
${app.installationStep.description || translate('apps.instructions', 'Follow the steps below to finish the setup')}
+ ${renderStepButtons(app.installationStep.buttons)}
`; } - if (app.addSubscriptionStep) { + if (app.openStep) { html += `
${stepNum++} - Add Subscription + ${translate('apps.open', 'Open the App')}
-
${getLocalizedText(app.addSubscriptionStep.description)}
+
${app.openStep.description || ''}
+ ${renderStepButtons(app.openStep.buttons)}
`; } - if (app.connectAndUseStep) { + if (app.importStep) { html += `
${stepNum++} - Connect & Use + ${translate('apps.import', 'Import Subscription')}
-
${getLocalizedText(app.connectAndUseStep.description)}
+
${app.importStep.description || ''}
+ ${renderStepButtons(app.importStep.buttons)} +
+ `; + } + + if (app.manualStep) { + html += ` +
+
+ ${stepNum++} + ${translate('apps.manual', 'Configure Manually')} +
+
${app.manualStep.description || ''}
+ ${renderStepButtons(app.manualStep.buttons)}
`; } @@ -799,38 +1192,18 @@ return html; } - function getLocalizedText(textObj) { - if (!textObj) { + function renderStepButtons(buttons) { + if (!Array.isArray(buttons) || !buttons.length) { return ''; } - if (typeof textObj === 'string') { - return textObj; - } - const telegramLang = tg.initDataUnsafe?.user?.language_code; - const preferenceOrder = [ - preferredLanguage, - preferredLanguage?.split('-')[0], - userData?.user?.language, - telegramLang, - telegramLang?.split('-')[0], - 'en', - 'ru' - ].filter(Boolean).map(lang => lang.toLowerCase()); + const html = buttons.map(btn => { + const text = btn.text || translate('apps.copy', 'Copy Link'); + const url = btn.url || '#'; + return `${text}`; + }).join(''); - const seen = new Set(); - for (const lang of preferenceOrder) { - if (seen.has(lang)) { - continue; - } - seen.add(lang); - if (textObj[lang]) { - return textObj[lang]; - } - } - - const fallback = Object.values(textObj).find(value => typeof value === 'string' && value.trim().length); - return fallback || ''; + return `
${html}
`; } function formatTraffic(value) { @@ -850,25 +1223,212 @@ function formatTrafficLimit(limit) { const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0'); if (!Number.isFinite(numeric) || numeric <= 0) { - return 'Unlimited'; + return translate('traffic.unlimited', 'Unlimited'); } return `${numeric.toFixed(0)} GB`; } + function formatCurrency(amount) { + if (typeof amount !== 'number' || Number.isNaN(amount)) { + return '—'; + } + try { + return new Intl.NumberFormat(getLocale(), { + style: 'currency', + currency: preferredLanguage === 'ru' ? 'RUB' : 'USD', + currencyDisplay: 'symbol', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch (error) { + const currencySymbol = preferredLanguage === 'ru' ? '₽' : '$'; + return `${amount.toFixed(2)} ${currencySymbol}`; + } + } + + function renderBalance() { + const balanceElement = document.getElementById('balanceAmount'); + if (!balanceElement) { + return; + } + + const balanceRubles = userData?.user?.balance_rubles ?? userData?.balance_rubles; + const balanceKopeks = userData?.user?.balance_kopeks ?? userData?.balance_kopeks; + let amountRub = balanceRubles; + + if (typeof amountRub !== 'number' && typeof balanceKopeks === 'number') { + amountRub = balanceKopeks / 100; + } + + if (typeof amountRub !== 'number' || Number.isNaN(amountRub)) { + balanceElement.textContent = '—'; + return; + } + + balanceElement.textContent = formatCurrency(amountRub); + } + + function getTransactions() { + if (Array.isArray(userData?.transactions)) { + return userData.transactions; + } + if (Array.isArray(userData?.operations)) { + return userData.operations; + } + if (Array.isArray(userData?.history)) { + return userData.history; + } + return []; + } + + function formatTransactionAmount(transaction) { + const amountKopeks = transaction.amount_kopeks ?? Math.round((transaction.amount_rubles ?? 0) * 100); + const amountRubles = transaction.amount_rubles ?? (typeof amountKopeks === 'number' ? amountKopeks / 100 : 0); + return amountRubles; + } + + function renderTransactionHistory() { + const historyList = document.getElementById('historyList'); + if (!historyList) { + return; + } + + const transactions = getTransactions(); + if (!transactions.length) { + historyList.innerHTML = `
${translate('history.empty', 'There are no transactions yet')}
`; + return; + } + + const locale = getLocale(); + historyList.innerHTML = transactions.slice(0, 10).map(transaction => { + const createdAt = transaction.created_at ? new Date(transaction.created_at) : null; + const formattedDate = createdAt && !Number.isNaN(createdAt.getTime()) + ? createdAt.toLocaleString(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : ''; + + const amountRubles = formatTransactionAmount(transaction); + const amountFormatted = formatCurrency(amountRubles); + const isPositive = amountRubles >= 0; + + const description = transaction.description || transaction.title || ''; + const typeLabel = transaction.type ? transaction.type.replace(/_/g, ' ') : ''; + const finalTitle = description || typeLabel || translate('history.title', 'Latest Transactions'); + + return ` +
+
+ ${finalTitle} + ${formattedDate} +
+ ${amountFormatted} +
+ `; + }).join(''); + } + + function getConnectedServers() { + if (Array.isArray(userData?.links) && userData.links.length) { + return userData.links; + } + if (Array.isArray(userData?.connected_squads) && userData.connected_squads.length) { + return userData.connected_squads; + } + if (Array.isArray(userData?.user?.connected_squads) && userData.user.connected_squads.length) { + return userData.user.connected_squads; + } + return []; + } + + function renderConnectedServers() { + const serverList = document.getElementById('serverList'); + if (!serverList) { + return; + } + + const servers = getConnectedServers(); + if (!servers.length) { + serverList.innerHTML = `
${translate('servers.empty', 'No connected servers yet')}
`; + return; + } + + serverList.innerHTML = servers.map(server => { + if (typeof server === 'string') { + return ` +
+ ${server} + ${translate('status.active', 'Active')} +
+ `; + } + + const name = server.name || server.id || 'Server'; + const status = server.status ? translateStatus(server.status) : translate('status.active', 'Active'); + return ` +
+ ${name} + ${status} +
+ `; + }).join(''); + } + function getCurrentSubscriptionUrl() { - return userData?.subscription_url || userData?.subscriptionUrl || ''; + return userData?.subscriptionCryptoLink || userData?.subscriptionUrl || ''; + } + + function buildRedirectUrl(template, link) { + if (!template || !link) { + return ''; + } + + const encoded = encodeURIComponent(link); + const replacements = { + '{subscription_link}': encoded, + '{link}': encoded, + '{subscription_link_raw}': link, + '{link_raw}': link, + }; + + let result = template; + let replaced = false; + + Object.entries(replacements).forEach(([placeholder, value]) => { + if (result.includes(placeholder)) { + result = result.split(placeholder).join(value); + replaced = true; + } + }); + + if (!replaced) { + if (/[?=&]$/.test(result)) { + return `${result}${encoded}`; + } + return `${result}${encoded}`; + } + + return result; } function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); - const hasUrl = Boolean(getCurrentSubscriptionUrl()); + const subscriptionUrl = getCurrentSubscriptionUrl(); + const redirectLink = buildRedirectUrl(redirectTemplate, redirectTarget); if (connectBtn) { - connectBtn.disabled = !hasUrl; + const shouldShow = Boolean(redirectTemplate && redirectTarget && redirectLink); + connectBtn.classList.toggle('hidden', !shouldShow); + connectBtn.disabled = !shouldShow; + connectBtn.dataset.targetUrl = shouldShow ? redirectLink : ''; } if (copyBtn) { - copyBtn.disabled = !hasUrl || !navigator.clipboard; + copyBtn.disabled = !subscriptionUrl || !navigator.clipboard; } } @@ -892,22 +1452,19 @@ }); }); + document.querySelectorAll('.lang-btn').forEach(btn => { + btn.addEventListener('click', () => { + setLanguage(btn.dataset.lang); + }); + }); + document.getElementById('connectBtn')?.addEventListener('click', () => { - const subscriptionUrl = getCurrentSubscriptionUrl(); - if (!subscriptionUrl) { + const connectBtn = document.getElementById('connectBtn'); + const targetUrl = connectBtn?.dataset.targetUrl; + if (!targetUrl) { return; } - - const apps = getAppsForCurrentPlatform(); - const featuredApp = apps.find(app => app.isFeatured) || apps[0]; - - if (featuredApp?.urlScheme) { - window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`; - } else if (userData?.happ_link && featuredApp?.id === 'happ') { - window.location.href = userData.happ_link; - } else { - window.location.href = subscriptionUrl; - } + window.location.href = targetUrl; }); document.getElementById('copyBtn')?.addEventListener('click', async () => { @@ -918,10 +1475,16 @@ try { await navigator.clipboard.writeText(subscriptionUrl); - showPopup('Subscription link copied to clipboard', 'Copied'); + showPopup( + translate('popup.copiedText', 'Subscription link copied to clipboard'), + translate('popup.copiedTitle', 'Copied') + ); } catch (error) { console.warn('Clipboard copy failed:', error); - showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed'); + showPopup( + translate('popup.copyFailedText', 'Unable to copy the subscription link automatically. Please copy it manually.'), + translate('popup.copyFailedTitle', 'Copy failed') + ); } }); @@ -932,16 +1495,19 @@ const textElement = document.getElementById('errorText'); if (titleElement) { - titleElement.textContent = error?.title || 'Subscription Not Found'; + titleElement.textContent = error?.title || translate('errors.defaultTitle', 'Subscription Not Found'); } if (textElement) { - textElement.textContent = error?.message || 'Please contact support to activate your subscription'; + textElement.textContent = error?.message || translate('errors.defaultText', 'Please contact support to activate your subscription'); } document.getElementById('errorState').classList.remove('hidden'); updateActionButtons(); } + detectPlatform(); + setActivePlatformButton(); + renderApps(); init(); From 0ca4f9630a429fb6de18dd86d7355d5adb06ec77 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:10:35 +0300 Subject: [PATCH 04/40] Revert "Add localization and account data to mini app" --- app/webapi/routes/miniapp.py | 31 +- app/webapi/schemas/miniapp.py | 19 - miniapp/index.html | 760 +++++----------------------------- 3 files changed, 98 insertions(+), 712 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 136c6864..5c4d596c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3,13 +3,12 @@ from __future__ import annotations import logging from typing import Any, Dict, List, Optional -from sqlalchemy import select from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription, Transaction +from app.database.models import Subscription from app.services.subscription_service import SubscriptionService from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -196,32 +195,8 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, - balance_kopeks=user.balance_kopeks, - balance_rubles=round((user.balance_kopeks or 0) / 100, 2), ) - transactions_result = await db.execute( - select(Transaction) - .where(Transaction.user_id == user.id) - .order_by(Transaction.created_at.desc()) - .limit(20) - ) - transactions = transactions_result.scalars().all() - - serialized_transactions = [ - { - "id": transaction.id, - "type": transaction.type, - "amount_kopeks": transaction.amount_kopeks, - "amount_rubles": round(transaction.amount_kopeks / 100, 2), - "description": transaction.description, - "is_completed": transaction.is_completed, - "created_at": transaction.created_at, - "payment_method": transaction.payment_method, - } - for transaction in transactions - ] - return MiniAppSubscriptionResponse( subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, @@ -234,9 +209,5 @@ async def get_subscription_details( happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), - happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(), - transactions=serialized_transactions, - balance_kopeks=user.balance_kopeks, - balance_rubles=round((user.balance_kopeks or 0) / 100, 2), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 911202fb..9c5a4308 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -29,19 +29,6 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False - balance_kopeks: int = 0 - balance_rubles: float = 0.0 - - -class MiniAppTransaction(BaseModel): - id: int - type: str - amount_kopeks: int - amount_rubles: float - description: Optional[str] = None - is_completed: bool = True - created_at: datetime - payment_method: Optional[str] = None class MiniAppSubscriptionResponse(BaseModel): @@ -57,10 +44,4 @@ class MiniAppSubscriptionResponse(BaseModel): happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None - happ_cryptolink_redirect_template: Optional[str] = None - transactions: List[MiniAppTransaction] = Field(default_factory=list) - balance_kopeks: int = 0 - balance_rubles: float = 0.0 - - diff --git a/miniapp/index.html b/miniapp/index.html index 4a4dadaa..ba983c31 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -22,7 +22,7 @@ --tg-theme-button-color: #2481cc; --tg-theme-button-text-color: #ffffff; --tg-theme-secondary-bg-color: #f0f0f0; - + --primary: var(--tg-theme-button-color); --text-primary: var(--tg-theme-text-color); --text-secondary: var(--tg-theme-hint-color); @@ -44,25 +44,16 @@ } .container { - max-width: 520px; + max-width: 480px; margin: 0 auto; } /* Header */ .header { - display: flex; - flex-direction: column; - gap: 12px; + text-align: center; margin-bottom: 24px; } - .header-top { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - } - .logo { font-size: 28px; font-weight: 700; @@ -75,36 +66,6 @@ color: var(--text-secondary); } - .language-selector { - display: inline-flex; - background: var(--bg-secondary); - border-radius: 999px; - padding: 4px; - gap: 4px; - } - - .lang-btn { - border: none; - background: transparent; - color: var(--text-secondary); - font-size: 13px; - font-weight: 600; - padding: 6px 12px; - border-radius: 999px; - cursor: pointer; - transition: all 0.2s ease; - } - - .lang-btn.active { - background: var(--primary); - color: #ffffff; - box-shadow: 0 2px 6px rgba(36, 129, 204, 0.35); - } - - .lang-btn:active { - transform: scale(0.96); - } - /* Loading State */ .loading { text-align: center; @@ -264,101 +225,6 @@ text-align: right; } - /* Balance */ - .balance-amount { - font-size: 28px; - font-weight: 700; - color: var(--primary); - } - - .balance-hint { - margin-top: 6px; - font-size: 13px; - color: var(--text-secondary); - } - - /* History */ - .history-list { - display: flex; - flex-direction: column; - gap: 10px; - } - - .history-item { - display: flex; - justify-content: space-between; - align-items: center; - background: white; - border-radius: 10px; - padding: 12px; - } - - .history-item__info { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 13px; - } - - .history-item__title { - font-weight: 600; - color: var(--text-primary); - } - - .history-item__date { - color: var(--text-secondary); - font-size: 12px; - } - - .history-item__amount { - font-weight: 700; - font-size: 14px; - } - - .history-item__amount.positive { - color: #0f5132; - } - - .history-item__amount.negative { - color: #842029; - } - - .empty-placeholder { - font-size: 13px; - color: var(--text-secondary); - background: white; - border-radius: 10px; - padding: 16px; - text-align: center; - } - - /* Servers */ - .server-list { - display: flex; - flex-direction: column; - gap: 8px; - } - - .server-item { - background: white; - border-radius: 10px; - padding: 12px; - font-size: 13px; - display: flex; - justify-content: space-between; - align-items: center; - color: var(--text-primary); - } - - .server-item__name { - font-weight: 600; - } - - .server-item__status { - color: var(--text-secondary); - font-size: 12px; - } - /* Buttons */ .btn { width: 100%; @@ -569,29 +435,21 @@
-
-
- -
Secure & Fast Connection
-
-
- - -
-
+ +
Secure & Fast Connection
-
Loading your subscription...
+
Loading your subscription...
@@ -609,67 +467,48 @@
-
-
Days Left
+
Days Left
-
-
Servers
+
Servers
- Expires + Expires -
- Traffic Used + Traffic Used -
- Traffic Limit + Traffic Limit -
- -
-
Balance
-
-
Available funds for subscription payments
-
- - -
-
Connected Servers
-
-
- - -
-
Latest Transactions
-
-
-
-
Installation Guide
- +
Installation Guide
+
@@ -708,224 +547,10 @@ }); } - const fallbackLanguage = 'en'; - const supportedLanguages = ['en', 'ru']; - - const translations = { - en: { - header: { - title: 'RemnaWave VPN', - subtitle: 'Secure & Fast Connection', - }, - loading: { - message: 'Loading your subscription...' - }, - errors: { - defaultTitle: 'Subscription Not Found', - defaultText: 'Please contact support to activate your subscription', - authTitle: 'Authorization Error', - authText: 'Authorization failed. Please open the mini app from Telegram.' - }, - stats: { - daysLeft: 'Days Left', - servers: 'Servers', - }, - info: { - expires: 'Expires', - trafficUsed: 'Traffic Used', - trafficLimit: 'Traffic Limit', - }, - balance: { - title: 'Balance', - hint: 'Available funds for subscription payments' - }, - servers: { - title: 'Connected Servers', - empty: 'No connected servers yet' - }, - history: { - title: 'Latest Transactions', - empty: 'There are no transactions yet' - }, - buttons: { - connect: 'Connect to VPN', - copy: 'Copy Subscription Link' - }, - apps: { - title: 'Installation Guide', - download: 'Download & Install', - open: 'Open the App', - import: 'Import Subscription', - manual: 'Configure Manually', - copy: 'Copy Link', - instructions: 'Follow the steps below to finish the setup', - empty: 'No installation guide available for this platform yet.', - recommended: 'Recommended' - }, - status: { - active: 'Active', - expired: 'Expired', - trial: 'Trial', - disabled: 'Disabled', - unknown: 'Unknown' - }, - popup: { - copiedTitle: 'Copied', - copiedText: 'Subscription link copied to clipboard', - copyFailedTitle: 'Copy failed', - copyFailedText: 'Unable to copy the subscription link automatically. Please copy it manually.' - }, - traffic: { - unlimited: 'Unlimited' - } - }, - ru: { - header: { - title: 'RemnaWave VPN', - subtitle: 'Безопасное и быстрое подключение', - }, - loading: { - message: 'Загружаем данные подписки...' - }, - errors: { - defaultTitle: 'Подписка не найдена', - defaultText: 'Свяжитесь с поддержкой для активации подписки', - authTitle: 'Ошибка авторизации', - authText: 'Не удалось авторизоваться. Откройте мини-приложение из Telegram.' - }, - stats: { - daysLeft: 'Дней осталось', - servers: 'Серверы' - }, - info: { - expires: 'Истекает', - trafficUsed: 'Израсходовано трафика', - trafficLimit: 'Лимит трафика' - }, - balance: { - title: 'Баланс', - hint: 'Доступные средства для оплаты подписки' - }, - servers: { - title: 'Подключенные серверы', - empty: 'Подключенных серверов пока нет' - }, - history: { - title: 'История операций', - empty: 'Операций пока нет' - }, - buttons: { - connect: 'Подключиться', - copy: 'Скопировать ссылку подписки' - }, - apps: { - title: 'Инструкция по установке', - download: 'Скачайте и установите', - open: 'Откройте приложение', - import: 'Импортируйте подписку', - manual: 'Настройте вручную', - copy: 'Скопировать ссылку', - instructions: 'Следуйте шагам, чтобы завершить настройку', - empty: 'Инструкция для этой платформы пока недоступна.', - recommended: 'Рекомендуем' - }, - status: { - active: 'Активна', - expired: 'Истекла', - trial: 'Пробная', - disabled: 'Выключена', - unknown: 'Неизвестно' - }, - popup: { - copiedTitle: 'Скопировано', - copiedText: 'Ссылка на подписку скопирована', - copyFailedTitle: 'Не удалось скопировать', - copyFailedText: 'Не удалось скопировать ссылку автоматически. Скопируйте её вручную.' - }, - traffic: { - unlimited: 'Безлимит' - } - } - }; - let userData = null; let appsConfig = {}; let currentPlatform = 'android'; - let preferredLanguage = fallbackLanguage; - let redirectTemplate = null; - let redirectTarget = null; - - function getLocale() { - const mapping = { - ru: 'ru-RU', - en: 'en-US' - }; - return mapping[preferredLanguage] || mapping.en; - } - - function translate(key, fallback = '') { - const langPack = translations[preferredLanguage] || translations[fallbackLanguage]; - if (!key || typeof key !== 'string') { - return fallback; - } - - const parts = key.split('.'); - let value = langPack; - - for (const part of parts) { - if (value && typeof value === 'object' && part in value) { - value = value[part]; - } else { - value = null; - break; - } - } - - if (typeof value === 'string') { - return value; - } - - return fallback; - } - - function translateStatus(status) { - if (!status) { - return translate('status.unknown', 'Unknown'); - } - const normalized = status.toLowerCase(); - return translate(`status.${normalized}`, status.charAt(0).toUpperCase() + status.slice(1)); - } - - function applyTranslations() { - document.querySelectorAll('[data-i18n]').forEach(element => { - const key = element.dataset.i18n; - const translated = translate(key, element.textContent); - if (translated) { - element.textContent = translated; - } - }); - } - - function setLanguage(language, options = {}) { - const { skipRender = false } = options; - if (!supportedLanguages.includes(language)) { - language = fallbackLanguage; - } - preferredLanguage = language; - - document.querySelectorAll('.lang-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.lang === preferredLanguage); - }); - - applyTranslations(); - - if (!skipRender) { - renderUserData(); - renderTransactionHistory(); - renderConnectedServers(); - renderApps(); - } - } + let preferredLanguage = 'en'; function createError(title, message, status) { const error = new Error(message || title); @@ -944,17 +569,12 @@ if (telegramUser?.language_code) { preferredLanguage = telegramUser.language_code.split('-')[0]; } - setLanguage(preferredLanguage, { skipRender: true }); await loadAppsConfig(); const initData = tg.initData || ''; if (!initData) { - throw createError( - translate('errors.authTitle', 'Authorization Error'), - translate('errors.authText', 'Missing Telegram init data'), - 401 - ); + throw createError('Authorization Error', 'Missing Telegram init data'); } const response = await fetch('/miniapp/subscription', { @@ -966,12 +586,10 @@ }); if (!response.ok) { - let title = response.status === 401 - ? translate('errors.authTitle', 'Authorization Error') - : translate('errors.defaultTitle', 'Subscription Not Found'); let detail = response.status === 401 - ? translate('errors.authText', 'Authorization failed. Please open the mini app from Telegram.') - : translate('errors.defaultText', 'Subscription not found'); + ? 'Authorization failed. Please open the mini app from Telegram.' + : 'Subscription not found'; + let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; try { const errorPayload = await response.json(); @@ -988,14 +606,15 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; - redirectTemplate = userData.happ_cryptolink_redirect_template || null; - redirectTarget = userData.happ_crypto_link || userData.subscriptionCryptoLink || null; if (userData?.user?.language) { preferredLanguage = userData.user.language; } - setLanguage(preferredLanguage); + renderUserData(); + detectPlatform(); + setActivePlatformButton(); + renderApps(); document.getElementById('loadingState').classList.add('hidden'); document.getElementById('mainContent').classList.remove('hidden'); @@ -1037,7 +656,7 @@ const knownStatuses = ['active', 'expired', 'trial', 'disabled']; const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown'; const statusBadge = document.getElementById('statusBadge'); - statusBadge.textContent = user.status_label || translateStatus(statusClass); + statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); statusBadge.className = `status-badge status-${statusClass}`; const expiresAt = user.expires_at ? new Date(user.expires_at) : null; @@ -1048,11 +667,7 @@ } document.getElementById('daysLeft').textContent = daysLeft; document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime()) - ? expiresAt.toLocaleDateString(getLocale(), { - year: 'numeric', - month: 'long', - day: 'numeric' - }) + ? expiresAt.toLocaleDateString() : '—'; const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0; @@ -1063,7 +678,6 @@ document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); - renderBalance(); updateActionButtons(); } @@ -1109,13 +723,13 @@ const apps = getAppsForCurrentPlatform(); if (!apps.length) { - container.innerHTML = `
${translate('apps.empty', 'No installation guide available for this platform yet.')}
`; + container.innerHTML = '
No installation guide available for this platform yet.
'; return; } container.innerHTML = apps.map(app => { const iconChar = (app.name?.[0] || 'A').toUpperCase(); - const featuredBadge = app.isFeatured ? `${translate('apps.recommended', 'Recommended')}` : ''; + const featuredBadge = app.isFeatured ? 'Recommended' : ''; return `
@@ -1142,49 +756,42 @@
${stepNum++} - ${translate('apps.download', 'Download & Install')} + Download & Install
-
${app.installationStep.description || translate('apps.instructions', 'Follow the steps below to finish the setup')}
- ${renderStepButtons(app.installationStep.buttons)} + ${app.installationStep.description ? `
${getLocalizedText(app.installationStep.description)}
` : ''} + ${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? ` +
+ ${app.installationStep.buttons.map(btn => ` + + ${getLocalizedText(btn.buttonText)} + + `).join('')} +
+ ` : ''}
`; } - if (app.openStep) { + if (app.addSubscriptionStep) { html += `
${stepNum++} - ${translate('apps.open', 'Open the App')} + Add Subscription
-
${app.openStep.description || ''}
- ${renderStepButtons(app.openStep.buttons)} +
${getLocalizedText(app.addSubscriptionStep.description)}
`; } - if (app.importStep) { + if (app.connectAndUseStep) { html += `
${stepNum++} - ${translate('apps.import', 'Import Subscription')} + Connect & Use
-
${app.importStep.description || ''}
- ${renderStepButtons(app.importStep.buttons)} -
- `; - } - - if (app.manualStep) { - html += ` -
-
- ${stepNum++} - ${translate('apps.manual', 'Configure Manually')} -
-
${app.manualStep.description || ''}
- ${renderStepButtons(app.manualStep.buttons)} +
${getLocalizedText(app.connectAndUseStep.description)}
`; } @@ -1192,18 +799,38 @@ return html; } - function renderStepButtons(buttons) { - if (!Array.isArray(buttons) || !buttons.length) { + function getLocalizedText(textObj) { + if (!textObj) { return ''; } + if (typeof textObj === 'string') { + return textObj; + } - const html = buttons.map(btn => { - const text = btn.text || translate('apps.copy', 'Copy Link'); - const url = btn.url || '#'; - return `${text}`; - }).join(''); + const telegramLang = tg.initDataUnsafe?.user?.language_code; + const preferenceOrder = [ + preferredLanguage, + preferredLanguage?.split('-')[0], + userData?.user?.language, + telegramLang, + telegramLang?.split('-')[0], + 'en', + 'ru' + ].filter(Boolean).map(lang => lang.toLowerCase()); - return `
${html}
`; + const seen = new Set(); + for (const lang of preferenceOrder) { + if (seen.has(lang)) { + continue; + } + seen.add(lang); + if (textObj[lang]) { + return textObj[lang]; + } + } + + const fallback = Object.values(textObj).find(value => typeof value === 'string' && value.trim().length); + return fallback || ''; } function formatTraffic(value) { @@ -1223,212 +850,25 @@ function formatTrafficLimit(limit) { const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0'); if (!Number.isFinite(numeric) || numeric <= 0) { - return translate('traffic.unlimited', 'Unlimited'); + return 'Unlimited'; } return `${numeric.toFixed(0)} GB`; } - function formatCurrency(amount) { - if (typeof amount !== 'number' || Number.isNaN(amount)) { - return '—'; - } - try { - return new Intl.NumberFormat(getLocale(), { - style: 'currency', - currency: preferredLanguage === 'ru' ? 'RUB' : 'USD', - currencyDisplay: 'symbol', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount); - } catch (error) { - const currencySymbol = preferredLanguage === 'ru' ? '₽' : '$'; - return `${amount.toFixed(2)} ${currencySymbol}`; - } - } - - function renderBalance() { - const balanceElement = document.getElementById('balanceAmount'); - if (!balanceElement) { - return; - } - - const balanceRubles = userData?.user?.balance_rubles ?? userData?.balance_rubles; - const balanceKopeks = userData?.user?.balance_kopeks ?? userData?.balance_kopeks; - let amountRub = balanceRubles; - - if (typeof amountRub !== 'number' && typeof balanceKopeks === 'number') { - amountRub = balanceKopeks / 100; - } - - if (typeof amountRub !== 'number' || Number.isNaN(amountRub)) { - balanceElement.textContent = '—'; - return; - } - - balanceElement.textContent = formatCurrency(amountRub); - } - - function getTransactions() { - if (Array.isArray(userData?.transactions)) { - return userData.transactions; - } - if (Array.isArray(userData?.operations)) { - return userData.operations; - } - if (Array.isArray(userData?.history)) { - return userData.history; - } - return []; - } - - function formatTransactionAmount(transaction) { - const amountKopeks = transaction.amount_kopeks ?? Math.round((transaction.amount_rubles ?? 0) * 100); - const amountRubles = transaction.amount_rubles ?? (typeof amountKopeks === 'number' ? amountKopeks / 100 : 0); - return amountRubles; - } - - function renderTransactionHistory() { - const historyList = document.getElementById('historyList'); - if (!historyList) { - return; - } - - const transactions = getTransactions(); - if (!transactions.length) { - historyList.innerHTML = `
${translate('history.empty', 'There are no transactions yet')}
`; - return; - } - - const locale = getLocale(); - historyList.innerHTML = transactions.slice(0, 10).map(transaction => { - const createdAt = transaction.created_at ? new Date(transaction.created_at) : null; - const formattedDate = createdAt && !Number.isNaN(createdAt.getTime()) - ? createdAt.toLocaleString(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }) - : ''; - - const amountRubles = formatTransactionAmount(transaction); - const amountFormatted = formatCurrency(amountRubles); - const isPositive = amountRubles >= 0; - - const description = transaction.description || transaction.title || ''; - const typeLabel = transaction.type ? transaction.type.replace(/_/g, ' ') : ''; - const finalTitle = description || typeLabel || translate('history.title', 'Latest Transactions'); - - return ` -
-
- ${finalTitle} - ${formattedDate} -
- ${amountFormatted} -
- `; - }).join(''); - } - - function getConnectedServers() { - if (Array.isArray(userData?.links) && userData.links.length) { - return userData.links; - } - if (Array.isArray(userData?.connected_squads) && userData.connected_squads.length) { - return userData.connected_squads; - } - if (Array.isArray(userData?.user?.connected_squads) && userData.user.connected_squads.length) { - return userData.user.connected_squads; - } - return []; - } - - function renderConnectedServers() { - const serverList = document.getElementById('serverList'); - if (!serverList) { - return; - } - - const servers = getConnectedServers(); - if (!servers.length) { - serverList.innerHTML = `
${translate('servers.empty', 'No connected servers yet')}
`; - return; - } - - serverList.innerHTML = servers.map(server => { - if (typeof server === 'string') { - return ` -
- ${server} - ${translate('status.active', 'Active')} -
- `; - } - - const name = server.name || server.id || 'Server'; - const status = server.status ? translateStatus(server.status) : translate('status.active', 'Active'); - return ` -
- ${name} - ${status} -
- `; - }).join(''); - } - function getCurrentSubscriptionUrl() { - return userData?.subscriptionCryptoLink || userData?.subscriptionUrl || ''; - } - - function buildRedirectUrl(template, link) { - if (!template || !link) { - return ''; - } - - const encoded = encodeURIComponent(link); - const replacements = { - '{subscription_link}': encoded, - '{link}': encoded, - '{subscription_link_raw}': link, - '{link_raw}': link, - }; - - let result = template; - let replaced = false; - - Object.entries(replacements).forEach(([placeholder, value]) => { - if (result.includes(placeholder)) { - result = result.split(placeholder).join(value); - replaced = true; - } - }); - - if (!replaced) { - if (/[?=&]$/.test(result)) { - return `${result}${encoded}`; - } - return `${result}${encoded}`; - } - - return result; + return userData?.subscription_url || userData?.subscriptionUrl || ''; } function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); - const subscriptionUrl = getCurrentSubscriptionUrl(); - const redirectLink = buildRedirectUrl(redirectTemplate, redirectTarget); + const hasUrl = Boolean(getCurrentSubscriptionUrl()); if (connectBtn) { - const shouldShow = Boolean(redirectTemplate && redirectTarget && redirectLink); - connectBtn.classList.toggle('hidden', !shouldShow); - connectBtn.disabled = !shouldShow; - connectBtn.dataset.targetUrl = shouldShow ? redirectLink : ''; + connectBtn.disabled = !hasUrl; } if (copyBtn) { - copyBtn.disabled = !subscriptionUrl || !navigator.clipboard; + copyBtn.disabled = !hasUrl || !navigator.clipboard; } } @@ -1452,19 +892,22 @@ }); }); - document.querySelectorAll('.lang-btn').forEach(btn => { - btn.addEventListener('click', () => { - setLanguage(btn.dataset.lang); - }); - }); - document.getElementById('connectBtn')?.addEventListener('click', () => { - const connectBtn = document.getElementById('connectBtn'); - const targetUrl = connectBtn?.dataset.targetUrl; - if (!targetUrl) { + const subscriptionUrl = getCurrentSubscriptionUrl(); + if (!subscriptionUrl) { return; } - window.location.href = targetUrl; + + const apps = getAppsForCurrentPlatform(); + const featuredApp = apps.find(app => app.isFeatured) || apps[0]; + + if (featuredApp?.urlScheme) { + window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`; + } else if (userData?.happ_link && featuredApp?.id === 'happ') { + window.location.href = userData.happ_link; + } else { + window.location.href = subscriptionUrl; + } }); document.getElementById('copyBtn')?.addEventListener('click', async () => { @@ -1475,16 +918,10 @@ try { await navigator.clipboard.writeText(subscriptionUrl); - showPopup( - translate('popup.copiedText', 'Subscription link copied to clipboard'), - translate('popup.copiedTitle', 'Copied') - ); + showPopup('Subscription link copied to clipboard', 'Copied'); } catch (error) { console.warn('Clipboard copy failed:', error); - showPopup( - translate('popup.copyFailedText', 'Unable to copy the subscription link automatically. Please copy it manually.'), - translate('popup.copyFailedTitle', 'Copy failed') - ); + showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed'); } }); @@ -1495,19 +932,16 @@ const textElement = document.getElementById('errorText'); if (titleElement) { - titleElement.textContent = error?.title || translate('errors.defaultTitle', 'Subscription Not Found'); + titleElement.textContent = error?.title || 'Subscription Not Found'; } if (textElement) { - textElement.textContent = error?.message || translate('errors.defaultText', 'Please contact support to activate your subscription'); + textElement.textContent = error?.message || 'Please contact support to activate your subscription'; } document.getElementById('errorState').classList.remove('hidden'); updateActionButtons(); } - detectPlatform(); - setActivePlatformButton(); - renderApps(); init(); From d8a6b6b08cecdda5d9e6cce6625798e19a2862db Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:11:09 +0300 Subject: [PATCH 05/40] Enhance miniapp with localization and account data --- app/webapi/routes/miniapp.py | 45 +- app/webapi/schemas/miniapp.py | 18 + miniapp/index.html | 982 ++++++++++++++++++++++++++++++---- 3 files changed, 936 insertions(+), 109 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5c4d596c..282d909c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4,11 +4,12 @@ import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription +from app.database.models import Subscription, Transaction from app.services.subscription_service import SubscriptionService from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -20,6 +21,7 @@ from ..schemas.miniapp import ( MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, + MiniAppTransaction, ) @@ -68,6 +70,12 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) +def _format_amount_rubles(amount_kopeks: Optional[int]) -> float: + if not amount_kopeks: + return 0.0 + return round(amount_kopeks / 100, 2) + + def _resolve_display_name(user_data: Dict[str, Any]) -> str: username = user_data.get("username") if username: @@ -169,6 +177,35 @@ async def get_subscription_details( links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} + balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) + balance_rubles = _format_amount_rubles(balance_kopeks) + balance_label = f"{balance_rubles:.2f}" + balance_currency = getattr(user, "balance_currency", None) or "RUB" + + transactions_result = await db.execute( + select(Transaction) + .where(Transaction.user_id == user.id) + .order_by(Transaction.created_at.desc()) + .limit(10) + ) + transactions = [ + MiniAppTransaction( + id=tx.id, + type=tx.type, + amount_kopeks=tx.amount_kopeks or 0, + amount_rubles=_format_amount_rubles(tx.amount_kopeks), + description=tx.description, + payment_method=tx.payment_method, + is_completed=bool(tx.is_completed), + created_at=tx.created_at, + completed_at=tx.completed_at, + ) + for tx in transactions_result.scalars().all() + if tx is not None + ] + + redirect_template = settings.get_happ_cryptolink_redirect_template() + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -195,6 +232,10 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, + balance_kopeks=balance_kopeks, + balance_rubles=balance_rubles, + balance_label=balance_label, + balance_currency=balance_currency, ) return MiniAppSubscriptionResponse( @@ -209,5 +250,7 @@ async def get_subscription_details( happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), + happ_cryptolink_redirect_template=redirect_template, + transactions=transactions, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 9c5a4308..e54e7f89 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -10,6 +10,18 @@ class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") +class MiniAppTransaction(BaseModel): + id: int + type: Optional[str] = None + amount_kopeks: int = 0 + amount_rubles: float = 0.0 + description: Optional[str] = None + payment_method: Optional[str] = None + is_completed: bool = False + created_at: datetime + completed_at: Optional[datetime] = None + + class MiniAppSubscriptionUser(BaseModel): telegram_id: int username: Optional[str] = None @@ -29,6 +41,10 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + balance_label: str + balance_currency: str = "RUB" class MiniAppSubscriptionResponse(BaseModel): @@ -44,4 +60,6 @@ class MiniAppSubscriptionResponse(BaseModel): happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None + happ_cryptolink_redirect_template: Optional[str] = None + transactions: List[MiniAppTransaction] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index ba983c31..ab303397 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -50,10 +50,19 @@ /* Header */ .header { - text-align: center; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; margin-bottom: 24px; } + .header-text { + flex: 1 1 auto; + min-width: 0; + } + .logo { font-size: 28px; font-weight: 700; @@ -64,6 +73,28 @@ .subtitle { font-size: 14px; color: var(--text-secondary); + white-space: nowrap; + } + + .language-selector { + display: flex; + align-items: center; + gap: 8px; + } + + .language-selector label { + font-size: 12px; + color: var(--text-secondary); + } + + .language-selector select { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: white; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; } /* Loading State */ @@ -200,6 +231,103 @@ color: var(--text-secondary); } + .balance-amount { + font-size: 28px; + font-weight: 700; + color: var(--primary); + } + + .balance-meta { + margin-top: 4px; + font-size: 13px; + color: var(--text-secondary); + } + + .section-subtitle { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin: 16px 0 8px; + } + + .transaction-list, + .server-list { + list-style: none; + margin: 0; + padding: 0; + } + + .transaction-item, + .server-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + } + + .transaction-item:last-child, + .server-item:last-child { + border-bottom: none; + } + + .transaction-info { + flex: 1; + min-width: 0; + } + + .transaction-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + } + + .transaction-meta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .transaction-amount { + font-size: 15px; + font-weight: 700; + white-space: nowrap; + } + + .transaction-amount.positive { + color: #13854f; + } + + .transaction-amount.negative { + color: #b42318; + } + + .server-item { + font-size: 14px; + color: var(--text-primary); + } + + .server-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .server-description { + font-size: 12px; + color: var(--text-secondary); + } + + .empty-placeholder { + font-size: 13px; + color: var(--text-secondary); + padding: 8px 0; + } + /* Info Items */ .info-item { display: flex; @@ -435,21 +563,30 @@
- -
Secure & Fast Connection
+
+ +
Secure & Fast Connection
+
+
+ + +
-
Loading your subscription...
+
Loading your subscription...
@@ -460,31 +597,31 @@
U
-
-
Days Left
+
Days Left
-
-
Servers
+
Servers
- Expires + Expires -
- Traffic Used + Traffic Used -
- Traffic Limit + Traffic Limit -
@@ -494,21 +631,39 @@ - Connect to VPN + Connect to VPN + +
+
Balance
+
+
+ +
Recent Transactions
+
    +
    No transactions yet
    +
    + + +
    +
    Connected Servers
    +
      +
      No servers connected yet
      +
      +
      -
      Installation Guide
      - +
      Installation Guide
      +
      @@ -551,6 +706,264 @@ let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; + const supportedLanguages = ['en', 'ru']; + let currentLanguage = 'en'; + + const translations = { + en: { + title: 'RemnaWave VPN', + subtitle: 'Secure & Fast Connection', + language: { + label: 'Language', + options: { + en: 'English', + ru: 'Russian', + }, + }, + loading: { + message: 'Loading your subscription...', + }, + errors: { + defaultTitle: 'Subscription Not Found', + defaultMessage: 'Please contact support to activate your subscription', + authorizationTitle: 'Authorization Error', + authorizationMessage: 'Authorization failed. Please open the mini app from Telegram.', + missingInitData: 'Missing Telegram init data', + }, + buttons: { + connect: 'Connect to VPN', + copy: 'Copy Subscription Link', + }, + stats: { + daysLeft: 'Days Left', + servers: 'Servers', + }, + info: { + expires: 'Expires', + trafficUsed: 'Traffic Used', + trafficLimit: 'Traffic Limit', + unlimited: 'Unlimited', + }, + balance: { + title: 'Balance', + currencyLabel: 'Currency:', + }, + transactions: { + title: 'Recent Transactions', + empty: 'No transactions yet', + completed: 'Completed', + pending: 'Pending', + meta: { + status: 'Status:', + payment: 'Payment method:', + date: 'Date:', + }, + types: { + topup: 'Top up', + purchase: 'Purchase', + refund: 'Refund', + adjustment: 'Adjustment', + }, + fallbackTitle: 'Payment', + }, + servers: { + title: 'Connected Servers', + empty: 'No servers connected yet', + }, + installation: { + title: 'Installation Guide', + recommended: 'Recommended', + noApps: 'No installation guide available for this platform yet.', + defaultAppName: 'App', + steps: { + download: 'Download & Install', + add: 'Add Subscription', + connect: 'Connect & Use', + }, + }, + status: { + active: 'Active', + expired: 'Expired', + trial: 'Trial', + disabled: 'Disabled', + unknown: 'Unknown', + }, + copy: { + success: 'Subscription link copied to clipboard', + error: 'Unable to copy the subscription link automatically. Please copy it manually.', + successTitle: 'Copied', + errorTitle: 'Copy failed', + }, + popup: { + defaultTitle: 'Info', + }, + }, + ru: { + title: 'RemnaWave VPN', + subtitle: 'Безопасное и быстрое подключение', + language: { + label: 'Язык', + options: { + en: 'Английский', + ru: 'Русский', + }, + }, + loading: { + message: 'Загружаем вашу подписку…', + }, + errors: { + defaultTitle: 'Подписка не найдена', + defaultMessage: 'Свяжитесь с поддержкой для активации подписки', + authorizationTitle: 'Ошибка авторизации', + authorizationMessage: 'Авторизация не удалась. Откройте мини-приложение из Telegram.', + missingInitData: 'Отсутствуют данные авторизации Telegram', + }, + buttons: { + connect: 'Подключиться', + copy: 'Скопировать ссылку', + }, + stats: { + daysLeft: 'Осталось дней', + servers: 'Серверы', + }, + info: { + expires: 'Действует до', + trafficUsed: 'Использовано трафика', + trafficLimit: 'Лимит трафика', + unlimited: 'Безлимит', + }, + balance: { + title: 'Баланс', + currencyLabel: 'Валюта:', + }, + transactions: { + title: 'История операций', + empty: 'Операций пока нет', + completed: 'Завершено', + pending: 'В обработке', + meta: { + status: 'Статус:', + payment: 'Способ оплаты:', + date: 'Дата:', + }, + types: { + topup: 'Пополнение', + purchase: 'Покупка', + refund: 'Возврат', + adjustment: 'Корректировка', + }, + fallbackTitle: 'Платёж', + }, + servers: { + title: 'Подключённые серверы', + empty: 'Нет подключённых серверов', + }, + installation: { + title: 'Инструкция по установке', + recommended: 'Рекомендуем', + noApps: 'Пока нет инструкции для этой платформы.', + defaultAppName: 'Приложение', + steps: { + download: 'Скачать и установить', + add: 'Добавить подписку', + connect: 'Подключиться и пользоваться', + }, + }, + status: { + active: 'Активна', + expired: 'Истекла', + trial: 'Пробный период', + disabled: 'Отключена', + unknown: 'Неизвестно', + }, + copy: { + success: 'Ссылка на подписку скопирована', + error: 'Не удалось скопировать ссылку автоматически. Скопируйте её вручную.', + successTitle: 'Скопировано', + errorTitle: 'Ошибка копирования', + }, + popup: { + defaultTitle: 'Информация', + }, + }, + }; + + function normalizeLanguage(lang) { + if (!lang) { + return 'en'; + } + const lower = String(lang).toLowerCase(); + if (supportedLanguages.includes(lower)) { + return lower; + } + const match = supportedLanguages.find(code => lower.startsWith(code)); + return match || 'en'; + } + + function getTranslationValue(lang, keyPath) { + const segments = keyPath.split('.'); + let cursor = translations[lang]; + for (const segment of segments) { + if (cursor && Object.prototype.hasOwnProperty.call(cursor, segment)) { + cursor = cursor[segment]; + } else { + return null; + } + } + return typeof cursor === 'string' ? cursor : null; + } + + function t(key) { + const normalized = normalizeLanguage(currentLanguage); + const primary = getTranslationValue(normalized, key); + if (primary) { + return primary; + } + return getTranslationValue('en', key) || key; + } + + function updateStaticTexts() { + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + if (!key) { + return; + } + const translation = t(key); + if (translation) { + element.textContent = translation; + } + }); + } + + function applyLanguage(lang) { + const normalized = normalizeLanguage(lang); + currentLanguage = normalized; + preferredLanguage = normalized; + document.documentElement.lang = normalized; + const select = document.getElementById('languageSelect'); + if (select && select.value !== normalized) { + select.value = normalized; + } + updateStaticTexts(); + if (userData?.user) { + renderUserData(); + } else { + renderTransactions(); + renderServerList(); + } + renderApps(); + updateActionButtons(); + } + + function setupLanguageSelector() { + const select = document.getElementById('languageSelect'); + if (!select) { + return; + } + select.addEventListener('change', event => { + applyLanguage(event.target.value); + }); + } function createError(title, message, status) { const error = new Error(message || title); @@ -567,14 +980,17 @@ try { const telegramUser = tg.initDataUnsafe?.user; if (telegramUser?.language_code) { - preferredLanguage = telegramUser.language_code.split('-')[0]; + preferredLanguage = normalizeLanguage(telegramUser.language_code); + applyLanguage(preferredLanguage); + } else { + applyLanguage(currentLanguage); } await loadAppsConfig(); const initData = tg.initData || ''; if (!initData) { - throw createError('Authorization Error', 'Missing Telegram init data'); + throw createError(t('errors.authorizationTitle'), t('errors.missingInitData')); } const response = await fetch('/miniapp/subscription', { @@ -587,9 +1003,9 @@ if (!response.ok) { let detail = response.status === 401 - ? 'Authorization failed. Please open the mini app from Telegram.' - : 'Subscription not found'; - let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; + ? t('errors.authorizationMessage') + : t('errors.defaultMessage'); + let title = response.status === 401 ? t('errors.authorizationTitle') : t('errors.defaultTitle'); try { const errorPayload = await response.json(); @@ -605,19 +1021,24 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; - userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + userData.subscriptionCryptoLink = userData.subscription_crypto_link || userData.subscriptionCryptoLink || null; if (userData?.user?.language) { - preferredLanguage = userData.user.language; + preferredLanguage = normalizeLanguage(userData.user.language); + applyLanguage(preferredLanguage); + } else { + applyLanguage(currentLanguage); } + document.getElementById('errorState')?.classList.add('hidden'); + renderUserData(); detectPlatform(); setActivePlatformButton(); renderApps(); - document.getElementById('loadingState').classList.add('hidden'); - document.getElementById('mainContent').classList.remove('hidden'); + document.getElementById('loadingState')?.classList.add('hidden'); + document.getElementById('mainContent')?.classList.remove('hidden'); } catch (error) { console.error('Initialization error:', error); showError(error); @@ -649,38 +1070,281 @@ const fallbackName = rawName || [user.first_name, user.last_name].filter(Boolean).join(' ') || `User ${user.telegram_id || ''}`.trim(); const avatarChar = (fallbackName.replace(/^@/, '')[0] || 'U').toUpperCase(); - document.getElementById('userAvatar').textContent = avatarChar; - document.getElementById('userName').textContent = fallbackName; + const avatarEl = document.getElementById('userAvatar'); + const nameEl = document.getElementById('userName'); + if (avatarEl) { + avatarEl.textContent = avatarChar; + } + if (nameEl) { + nameEl.textContent = fallbackName; + } const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase(); const knownStatuses = ['active', 'expired', 'trial', 'disabled']; const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown'; const statusBadge = document.getElementById('statusBadge'); - statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); - statusBadge.className = `status-badge status-${statusClass}`; + if (statusBadge) { + const translatedStatus = t(`status.${statusClass}`); + statusBadge.textContent = translatedStatus || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); + statusBadge.className = `status-badge status-${statusClass}`; + } const expiresAt = user.expires_at ? new Date(user.expires_at) : null; let daysLeft = '—'; if (expiresAt && !Number.isNaN(expiresAt.getTime())) { const diffDays = Math.ceil((expiresAt - new Date()) / (1000 * 60 * 60 * 24)); - daysLeft = diffDays > 0 ? diffDays : '0'; + daysLeft = diffDays > 0 ? String(diffDays) : '0'; + } + const daysLeftEl = document.getElementById('daysLeft'); + if (daysLeftEl) { + daysLeftEl.textContent = daysLeft; } - document.getElementById('daysLeft').textContent = daysLeft; - document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime()) - ? expiresAt.toLocaleDateString() - : '—'; - const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0; - document.getElementById('serversCount').textContent = serversCount; + const expiresEl = document.getElementById('expiresAt'); + if (expiresEl) { + expiresEl.textContent = formatDate(expiresAt); + } - document.getElementById('trafficUsed').textContent = - user.traffic_used_label || formatTraffic(user.traffic_used_gb); - document.getElementById('trafficLimit').textContent = - user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + const trafficUsedEl = document.getElementById('trafficUsed'); + if (trafficUsedEl) { + trafficUsedEl.textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); + } + const trafficLimitEl = document.getElementById('trafficLimit'); + if (trafficLimitEl) { + trafficLimitEl.textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + } + + updateServerCount(); + renderBalance(); + renderTransactions(); + renderServerList(); updateActionButtons(); } + function renderBalance() { + const amountEl = document.getElementById('balanceAmount'); + const metaEl = document.getElementById('balanceMeta'); + if (!amountEl || !metaEl || !userData?.user) { + return; + } + const currency = getCurrencyCode(userData.user.balance_currency); + const balanceValue = getAmountInRubles(userData.user.balance_rubles, userData.user.balance_kopeks); + amountEl.textContent = formatCurrencyAmount(balanceValue, currency, { withSign: false }); + metaEl.textContent = `${t('balance.currencyLabel')} ${currency}`; + } + + function getAmountInRubles(amountRubles, amountKopeks) { + if (typeof amountRubles === 'number' && Number.isFinite(amountRubles)) { + return amountRubles; + } + const numericRubles = Number.parseFloat(amountRubles); + if (Number.isFinite(numericRubles)) { + return numericRubles; + } + const kopeks = Number.parseInt(amountKopeks ?? 0, 10); + if (Number.isFinite(kopeks)) { + return kopeks / 100; + } + return 0; + } + + function renderTransactions() { + const listEl = document.getElementById('transactionsList'); + const emptyEl = document.getElementById('transactionsEmpty'); + if (!listEl || !emptyEl) { + return; + } + + const transactions = Array.isArray(userData?.transactions) ? userData.transactions : []; + if (!transactions.length) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + return; + } + + emptyEl.classList.add('hidden'); + const currency = getCurrencyCode(userData?.user?.balance_currency); + + listEl.innerHTML = transactions.map(tx => { + const title = escapeHtml(getTransactionTitle(tx)); + const statusLabel = tx?.is_completed ? t('transactions.completed') : t('transactions.pending'); + const amountValue = getTransactionAmountValue(tx); + const amountClass = amountValue >= 0 ? 'positive' : 'negative'; + const amountText = escapeHtml(formatCurrencyAmount(amountValue, currency, { withSign: true })); + const metaParts = []; + const dateLabel = formatDateTime(tx?.created_at); + if (dateLabel) { + metaParts.push(`${t('transactions.meta.date')} ${dateLabel}`); + } + if (tx?.payment_method) { + metaParts.push(`${t('transactions.meta.payment')} ${tx.payment_method}`); + } + metaParts.push(`${t('transactions.meta.status')} ${statusLabel}`); + const metaHtml = metaParts.map(part => `${escapeHtml(part)}`).join(' • '); + + return ` +
    • +
      +
      ${title}
      +
      ${metaHtml}
      +
      +
      ${amountText}
      +
    • + `; + }).join(''); + } + + function getTransactionAmountValue(tx) { + if (!tx) { + return 0; + } + return getAmountInRubles(tx.amount_rubles, tx.amount_kopeks); + } + + function getTransactionTitle(tx) { + if (!tx) { + return t('transactions.fallbackTitle'); + } + const description = typeof tx.description === 'string' ? tx.description.trim() : ''; + if (description) { + return description; + } + const type = typeof tx.type === 'string' ? tx.type.toLowerCase() : ''; + const translated = t(`transactions.types.${type}`); + if (translated && translated !== `transactions.types.${type}`) { + return translated; + } + return t('transactions.fallbackTitle'); + } + + function formatDateTime(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + try { + return date.toLocaleString(currentLanguage, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch (error) { + return date.toISOString().replace('T', ' ').split('.')[0]; + } + } + + function renderServerList() { + const listEl = document.getElementById('serversList'); + const emptyEl = document.getElementById('serversEmpty'); + if (!listEl || !emptyEl) { + return; + } + + const servers = getServerList(); + if (!servers.length) { + listEl.innerHTML = ''; + emptyEl.classList.remove('hidden'); + updateServerCount(); + return; + } + + emptyEl.classList.add('hidden'); + updateServerCount(); + + listEl.innerHTML = servers.map(server => { + const description = server.description ? `
      ${escapeHtml(server.description)}
      ` : ''; + return ` +
    • +
      +
      ${escapeHtml(server.name)}
      + ${description} +
      +
    • + `; + }).join(''); + } + + function getServerList() { + if (!userData) { + return []; + } + const items = []; + if (Array.isArray(userData.connected_squads) && userData.connected_squads.length) { + userData.connected_squads.forEach(item => { + const normalized = normalizeServer(item); + if (normalized) { + items.push(normalized); + } + }); + } + if (!items.length && Array.isArray(userData.links)) { + userData.links.forEach(link => { + const name = extractServerName(link); + if (name) { + items.push({ name, description: '' }); + } + }); + } + const seen = new Set(); + return items.filter(item => { + const key = `${item.name}|${item.description}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + + function normalizeServer(item) { + if (!item) { + return null; + } + if (typeof item === 'string') { + const trimmed = item.trim(); + if (!trimmed) { + return null; + } + return { name: trimmed, description: '' }; + } + if (typeof item === 'object') { + const name = item.name || item.title || item.id || item.slug || ''; + const description = item.description || item.location || ''; + if (!name) { + return null; + } + return { name: String(name), description: description ? String(description) : '' }; + } + return null; + } + + function extractServerName(link) { + if (typeof link !== 'string') { + return ''; + } + try { + const url = new URL(link); + return url.hostname || link; + } catch (error) { + return link; + } + } + + function updateServerCount() { + const countEl = document.getElementById('serversCount'); + if (!countEl) { + return; + } + const count = getServerList().length; + countEl.textContent = String(count); + } + function detectPlatform() { const userAgent = navigator.userAgent.toLowerCase(); if (userAgent.includes('iphone') || userAgent.includes('ipad')) { @@ -723,19 +1387,20 @@ const apps = getAppsForCurrentPlatform(); if (!apps.length) { - container.innerHTML = '
      No installation guide available for this platform yet.
      '; + container.innerHTML = `
      ${escapeHtml(t('installation.noApps'))}
      `; return; } container.innerHTML = apps.map(app => { const iconChar = (app.name?.[0] || 'A').toUpperCase(); - const featuredBadge = app.isFeatured ? 'Recommended' : ''; + const featuredBadge = app.isFeatured ? `${escapeHtml(t('installation.recommended'))}` : ''; + const appName = escapeHtml(app.name || t('installation.defaultAppName')); return `
      -
      ${iconChar}
      +
      ${escapeHtml(iconChar)}
      -
      ${app.name || 'App'}
      +
      ${appName}
      ${featuredBadge}
      @@ -756,7 +1421,7 @@
      ${stepNum++} - Download & Install + ${escapeHtml(t('installation.steps.download'))}
      ${app.installationStep.description ? `
      ${getLocalizedText(app.installationStep.description)}
      ` : ''} ${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? ` @@ -777,7 +1442,7 @@
      ${stepNum++} - Add Subscription + ${escapeHtml(t('installation.steps.add'))}
      ${getLocalizedText(app.addSubscriptionStep.description)}
      @@ -789,7 +1454,7 @@
      ${stepNum++} - Connect & Use + ${escapeHtml(t('installation.steps.connect'))}
      ${getLocalizedText(app.connectAndUseStep.description)}
      @@ -809,9 +1474,12 @@ const telegramLang = tg.initDataUnsafe?.user?.language_code; const preferenceOrder = [ + currentLanguage, + currentLanguage?.split('-')[0], preferredLanguage, preferredLanguage?.split('-')[0], userData?.user?.language, + userData?.user?.language?.split('-')[0], telegramLang, telegramLang?.split('-')[0], 'en', @@ -838,110 +1506,208 @@ if (!Number.isFinite(numeric)) { return '0 GB'; } - if (numeric >= 100) { - return `${numeric.toFixed(0)} GB`; + const digits = numeric >= 100 ? 0 : numeric >= 10 ? 1 : 2; + try { + return `${new Intl.NumberFormat(currentLanguage, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(numeric)} GB`; + } catch (error) { + return `${numeric.toFixed(digits)} GB`; } - if (numeric >= 10) { - return `${numeric.toFixed(1)} GB`; - } - return `${numeric.toFixed(2)} GB`; } function formatTrafficLimit(limit) { const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0'); if (!Number.isFinite(numeric) || numeric <= 0) { - return 'Unlimited'; + return t('info.unlimited'); + } + try { + return `${new Intl.NumberFormat(currentLanguage, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(numeric)} GB`; + } catch (error) { + return `${numeric.toFixed(0)} GB`; } - return `${numeric.toFixed(0)} GB`; } - function getCurrentSubscriptionUrl() { - return userData?.subscription_url || userData?.subscriptionUrl || ''; + function formatCurrencyAmount(amount, currency, options = {}) { + const normalizedCurrency = getCurrencyCode(currency); + const value = typeof amount === 'number' ? amount : Number.parseFloat(amount ?? '0'); + const withSign = Boolean(options.withSign); + if (!Number.isFinite(value)) { + return withSign ? `+0.00 ${normalizedCurrency}` : `0.00 ${normalizedCurrency}`; + } + try { + return new Intl.NumberFormat(currentLanguage, { + style: 'currency', + currency: normalizedCurrency, + signDisplay: withSign ? 'always' : 'auto', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + } catch (error) { + const sign = withSign && value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(2)} ${normalizedCurrency}`; + } + } + + function getCurrencyCode(currency) { + const fallback = 'RUB'; + if (!currency || typeof currency !== 'string') { + return fallback; + } + const trimmed = currency.trim(); + return trimmed ? trimmed.toUpperCase() : fallback; + } + + function getCryptolinkRedirectUrl() { + if (!userData) { + return null; + } + const template = userData.happ_cryptolink_redirect_template || userData.happCryptolinkRedirectTemplate; + const cryptoLink = userData.subscriptionCryptoLink || userData.happ_crypto_link; + return buildRedirectUrl(template, cryptoLink); + } + + function buildRedirectUrl(template, target) { + if (!template || !target) { + return null; + } + const trimmed = template.trim(); + if (!trimmed) { + return null; + } + const encodedTarget = encodeURIComponent(target); + if (trimmed.includes('{url}')) { + return trimmed.replace('{url}', encodedTarget); + } + if (/[?&]$/.test(trimmed) || trimmed.endsWith('=')) { + return `${trimmed}${encodedTarget}`; + } + if (trimmed.includes('?')) { + return `${trimmed}&redirect_to=${encodedTarget}`; + } + return `${trimmed}?redirect_to=${encodedTarget}`; + } + + function getSubscriptionCopyLink() { + if (!userData) { + return ''; + } + return userData.subscriptionUrl + || userData.subscription_url + || userData.subscriptionCryptoLink + || userData.subscription_crypto_link + || ''; } function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); - const hasUrl = Boolean(getCurrentSubscriptionUrl()); + const redirectUrl = getCryptolinkRedirectUrl(); + const copyLink = getSubscriptionCopyLink(); if (connectBtn) { - connectBtn.disabled = !hasUrl; + connectBtn.disabled = !redirectUrl; + connectBtn.classList.toggle('hidden', !redirectUrl); } if (copyBtn) { - copyBtn.disabled = !hasUrl || !navigator.clipboard; + copyBtn.disabled = !copyLink || !navigator.clipboard; } } - function showPopup(message, title = 'Info') { + function showPopup(message, title) { + const popupTitle = title || t('popup.defaultTitle'); if (typeof tg.showPopup === 'function') { tg.showPopup({ - title, + title: popupTitle, message, buttons: [{ type: 'ok' }] }); } else { - alert(message); + alert(`${popupTitle}\n\n${message}`); } } - document.querySelectorAll('.platform-btn').forEach(btn => { - btn.addEventListener('click', () => { - currentPlatform = btn.dataset.platform; - setActivePlatformButton(); - renderApps(); - }); - }); - - document.getElementById('connectBtn')?.addEventListener('click', () => { - const subscriptionUrl = getCurrentSubscriptionUrl(); - if (!subscriptionUrl) { - return; - } - - const apps = getAppsForCurrentPlatform(); - const featuredApp = apps.find(app => app.isFeatured) || apps[0]; - - if (featuredApp?.urlScheme) { - window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`; - } else if (userData?.happ_link && featuredApp?.id === 'happ') { - window.location.href = userData.happ_link; - } else { - window.location.href = subscriptionUrl; - } - }); - - document.getElementById('copyBtn')?.addEventListener('click', async () => { - const subscriptionUrl = getCurrentSubscriptionUrl(); - if (!subscriptionUrl || !navigator.clipboard) { - return; - } - - try { - await navigator.clipboard.writeText(subscriptionUrl); - showPopup('Subscription link copied to clipboard', 'Copied'); - } catch (error) { - console.warn('Clipboard copy failed:', error); - showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed'); - } - }); - function showError(error) { - document.getElementById('loadingState').classList.add('hidden'); + document.getElementById('loadingState')?.classList.add('hidden'); + document.getElementById('mainContent')?.classList.add('hidden'); const titleElement = document.getElementById('errorTitle'); const textElement = document.getElementById('errorText'); if (titleElement) { - titleElement.textContent = error?.title || 'Subscription Not Found'; + titleElement.textContent = error?.title || t('errors.defaultTitle'); } if (textElement) { - textElement.textContent = error?.message || 'Please contact support to activate your subscription'; + textElement.textContent = error?.message || t('errors.defaultMessage'); } - document.getElementById('errorState').classList.remove('hidden'); + document.getElementById('errorState')?.classList.remove('hidden'); updateActionButtons(); } + function setupPlatformButtons() { + document.querySelectorAll('.platform-btn').forEach(btn => { + btn.addEventListener('click', () => { + currentPlatform = btn.dataset.platform; + setActivePlatformButton(); + renderApps(); + }); + }); + } + + document.getElementById('connectBtn')?.addEventListener('click', () => { + const redirectUrl = getCryptolinkRedirectUrl(); + if (!redirectUrl) { + return; + } + window.location.href = redirectUrl; + }); + + document.getElementById('copyBtn')?.addEventListener('click', async () => { + const copyLink = getSubscriptionCopyLink(); + if (!copyLink || !navigator.clipboard) { + return; + } + + try { + await navigator.clipboard.writeText(copyLink); + showPopup(t('copy.success'), t('copy.successTitle')); + } catch (error) { + console.warn('Clipboard copy failed:', error); + showPopup(t('copy.error'), t('copy.errorTitle')); + } + }); + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatDate(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return '—'; + } + try { + return date.toLocaleDateString(currentLanguage, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch (error) { + return date.toISOString().split('T')[0]; + } + } + + setupLanguageSelector(); + setupPlatformButtons(); init(); From 9db392b410ffabcf818941b8158832e3308aebaf Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:13:34 +0300 Subject: [PATCH 06/40] Revert "Add localization, balance, and server info to miniapp" --- app/webapi/routes/miniapp.py | 45 +- app/webapi/schemas/miniapp.py | 18 - miniapp/index.html | 982 ++++------------------------------ 3 files changed, 109 insertions(+), 936 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 282d909c..5c4d596c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4,12 +4,11 @@ import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription, Transaction +from app.database.models import Subscription from app.services.subscription_service import SubscriptionService from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -21,7 +20,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, - MiniAppTransaction, ) @@ -70,12 +68,6 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) -def _format_amount_rubles(amount_kopeks: Optional[int]) -> float: - if not amount_kopeks: - return 0.0 - return round(amount_kopeks / 100, 2) - - def _resolve_display_name(user_data: Dict[str, Any]) -> str: username = user_data.get("username") if username: @@ -177,35 +169,6 @@ async def get_subscription_details( links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - balance_rubles = _format_amount_rubles(balance_kopeks) - balance_label = f"{balance_rubles:.2f}" - balance_currency = getattr(user, "balance_currency", None) or "RUB" - - transactions_result = await db.execute( - select(Transaction) - .where(Transaction.user_id == user.id) - .order_by(Transaction.created_at.desc()) - .limit(10) - ) - transactions = [ - MiniAppTransaction( - id=tx.id, - type=tx.type, - amount_kopeks=tx.amount_kopeks or 0, - amount_rubles=_format_amount_rubles(tx.amount_kopeks), - description=tx.description, - payment_method=tx.payment_method, - is_completed=bool(tx.is_completed), - created_at=tx.created_at, - completed_at=tx.completed_at, - ) - for tx in transactions_result.scalars().all() - if tx is not None - ] - - redirect_template = settings.get_happ_cryptolink_redirect_template() - response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -232,10 +195,6 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, - balance_kopeks=balance_kopeks, - balance_rubles=balance_rubles, - balance_label=balance_label, - balance_currency=balance_currency, ) return MiniAppSubscriptionResponse( @@ -250,7 +209,5 @@ async def get_subscription_details( happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), - happ_cryptolink_redirect_template=redirect_template, - transactions=transactions, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index e54e7f89..9c5a4308 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -10,18 +10,6 @@ class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") -class MiniAppTransaction(BaseModel): - id: int - type: Optional[str] = None - amount_kopeks: int = 0 - amount_rubles: float = 0.0 - description: Optional[str] = None - payment_method: Optional[str] = None - is_completed: bool = False - created_at: datetime - completed_at: Optional[datetime] = None - - class MiniAppSubscriptionUser(BaseModel): telegram_id: int username: Optional[str] = None @@ -41,10 +29,6 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False - balance_kopeks: int = 0 - balance_rubles: float = 0.0 - balance_label: str - balance_currency: str = "RUB" class MiniAppSubscriptionResponse(BaseModel): @@ -60,6 +44,4 @@ class MiniAppSubscriptionResponse(BaseModel): happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None - happ_cryptolink_redirect_template: Optional[str] = None - transactions: List[MiniAppTransaction] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index ab303397..ba983c31 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -50,19 +50,10 @@ /* Header */ .header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; + text-align: center; margin-bottom: 24px; } - .header-text { - flex: 1 1 auto; - min-width: 0; - } - .logo { font-size: 28px; font-weight: 700; @@ -73,28 +64,6 @@ .subtitle { font-size: 14px; color: var(--text-secondary); - white-space: nowrap; - } - - .language-selector { - display: flex; - align-items: center; - gap: 8px; - } - - .language-selector label { - font-size: 12px; - color: var(--text-secondary); - } - - .language-selector select { - padding: 6px 10px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: white; - color: var(--text-primary); - font-size: 13px; - font-weight: 600; } /* Loading State */ @@ -231,103 +200,6 @@ color: var(--text-secondary); } - .balance-amount { - font-size: 28px; - font-weight: 700; - color: var(--primary); - } - - .balance-meta { - margin-top: 4px; - font-size: 13px; - color: var(--text-secondary); - } - - .section-subtitle { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - margin: 16px 0 8px; - } - - .transaction-list, - .server-list { - list-style: none; - margin: 0; - padding: 0; - } - - .transaction-item, - .server-item { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--border-color); - } - - .transaction-item:last-child, - .server-item:last-child { - border-bottom: none; - } - - .transaction-info { - flex: 1; - min-width: 0; - } - - .transaction-title { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 4px; - } - - .transaction-meta { - font-size: 12px; - color: var(--text-secondary); - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - .transaction-amount { - font-size: 15px; - font-weight: 700; - white-space: nowrap; - } - - .transaction-amount.positive { - color: #13854f; - } - - .transaction-amount.negative { - color: #b42318; - } - - .server-item { - font-size: 14px; - color: var(--text-primary); - } - - .server-name { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - } - - .server-description { - font-size: 12px; - color: var(--text-secondary); - } - - .empty-placeholder { - font-size: 13px; - color: var(--text-secondary); - padding: 8px 0; - } - /* Info Items */ .info-item { display: flex; @@ -563,30 +435,21 @@
      -
      - -
      Secure & Fast Connection
      -
      -
      - - -
      + +
      Secure & Fast Connection
      -
      Loading your subscription...
      +
      Loading your subscription...
      @@ -597,31 +460,31 @@
      U
      -
      -
      Days Left
      +
      Days Left
      -
      -
      Servers
      +
      Servers
      - Expires + Expires -
      - Traffic Used + Traffic Used -
      - Traffic Limit + Traffic Limit -
      @@ -631,39 +494,21 @@ - Connect to VPN + Connect to VPN - -
      -
      Balance
      -
      -
      - -
      Recent Transactions
      -
        -
        No transactions yet
        -
        - - -
        -
        Connected Servers
        -
          -
          No servers connected yet
          -
          -
          -
          Installation Guide
          - +
          Installation Guide
          +
          @@ -706,264 +551,6 @@ let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; - const supportedLanguages = ['en', 'ru']; - let currentLanguage = 'en'; - - const translations = { - en: { - title: 'RemnaWave VPN', - subtitle: 'Secure & Fast Connection', - language: { - label: 'Language', - options: { - en: 'English', - ru: 'Russian', - }, - }, - loading: { - message: 'Loading your subscription...', - }, - errors: { - defaultTitle: 'Subscription Not Found', - defaultMessage: 'Please contact support to activate your subscription', - authorizationTitle: 'Authorization Error', - authorizationMessage: 'Authorization failed. Please open the mini app from Telegram.', - missingInitData: 'Missing Telegram init data', - }, - buttons: { - connect: 'Connect to VPN', - copy: 'Copy Subscription Link', - }, - stats: { - daysLeft: 'Days Left', - servers: 'Servers', - }, - info: { - expires: 'Expires', - trafficUsed: 'Traffic Used', - trafficLimit: 'Traffic Limit', - unlimited: 'Unlimited', - }, - balance: { - title: 'Balance', - currencyLabel: 'Currency:', - }, - transactions: { - title: 'Recent Transactions', - empty: 'No transactions yet', - completed: 'Completed', - pending: 'Pending', - meta: { - status: 'Status:', - payment: 'Payment method:', - date: 'Date:', - }, - types: { - topup: 'Top up', - purchase: 'Purchase', - refund: 'Refund', - adjustment: 'Adjustment', - }, - fallbackTitle: 'Payment', - }, - servers: { - title: 'Connected Servers', - empty: 'No servers connected yet', - }, - installation: { - title: 'Installation Guide', - recommended: 'Recommended', - noApps: 'No installation guide available for this platform yet.', - defaultAppName: 'App', - steps: { - download: 'Download & Install', - add: 'Add Subscription', - connect: 'Connect & Use', - }, - }, - status: { - active: 'Active', - expired: 'Expired', - trial: 'Trial', - disabled: 'Disabled', - unknown: 'Unknown', - }, - copy: { - success: 'Subscription link copied to clipboard', - error: 'Unable to copy the subscription link automatically. Please copy it manually.', - successTitle: 'Copied', - errorTitle: 'Copy failed', - }, - popup: { - defaultTitle: 'Info', - }, - }, - ru: { - title: 'RemnaWave VPN', - subtitle: 'Безопасное и быстрое подключение', - language: { - label: 'Язык', - options: { - en: 'Английский', - ru: 'Русский', - }, - }, - loading: { - message: 'Загружаем вашу подписку…', - }, - errors: { - defaultTitle: 'Подписка не найдена', - defaultMessage: 'Свяжитесь с поддержкой для активации подписки', - authorizationTitle: 'Ошибка авторизации', - authorizationMessage: 'Авторизация не удалась. Откройте мини-приложение из Telegram.', - missingInitData: 'Отсутствуют данные авторизации Telegram', - }, - buttons: { - connect: 'Подключиться', - copy: 'Скопировать ссылку', - }, - stats: { - daysLeft: 'Осталось дней', - servers: 'Серверы', - }, - info: { - expires: 'Действует до', - trafficUsed: 'Использовано трафика', - trafficLimit: 'Лимит трафика', - unlimited: 'Безлимит', - }, - balance: { - title: 'Баланс', - currencyLabel: 'Валюта:', - }, - transactions: { - title: 'История операций', - empty: 'Операций пока нет', - completed: 'Завершено', - pending: 'В обработке', - meta: { - status: 'Статус:', - payment: 'Способ оплаты:', - date: 'Дата:', - }, - types: { - topup: 'Пополнение', - purchase: 'Покупка', - refund: 'Возврат', - adjustment: 'Корректировка', - }, - fallbackTitle: 'Платёж', - }, - servers: { - title: 'Подключённые серверы', - empty: 'Нет подключённых серверов', - }, - installation: { - title: 'Инструкция по установке', - recommended: 'Рекомендуем', - noApps: 'Пока нет инструкции для этой платформы.', - defaultAppName: 'Приложение', - steps: { - download: 'Скачать и установить', - add: 'Добавить подписку', - connect: 'Подключиться и пользоваться', - }, - }, - status: { - active: 'Активна', - expired: 'Истекла', - trial: 'Пробный период', - disabled: 'Отключена', - unknown: 'Неизвестно', - }, - copy: { - success: 'Ссылка на подписку скопирована', - error: 'Не удалось скопировать ссылку автоматически. Скопируйте её вручную.', - successTitle: 'Скопировано', - errorTitle: 'Ошибка копирования', - }, - popup: { - defaultTitle: 'Информация', - }, - }, - }; - - function normalizeLanguage(lang) { - if (!lang) { - return 'en'; - } - const lower = String(lang).toLowerCase(); - if (supportedLanguages.includes(lower)) { - return lower; - } - const match = supportedLanguages.find(code => lower.startsWith(code)); - return match || 'en'; - } - - function getTranslationValue(lang, keyPath) { - const segments = keyPath.split('.'); - let cursor = translations[lang]; - for (const segment of segments) { - if (cursor && Object.prototype.hasOwnProperty.call(cursor, segment)) { - cursor = cursor[segment]; - } else { - return null; - } - } - return typeof cursor === 'string' ? cursor : null; - } - - function t(key) { - const normalized = normalizeLanguage(currentLanguage); - const primary = getTranslationValue(normalized, key); - if (primary) { - return primary; - } - return getTranslationValue('en', key) || key; - } - - function updateStaticTexts() { - document.querySelectorAll('[data-i18n]').forEach(element => { - const key = element.getAttribute('data-i18n'); - if (!key) { - return; - } - const translation = t(key); - if (translation) { - element.textContent = translation; - } - }); - } - - function applyLanguage(lang) { - const normalized = normalizeLanguage(lang); - currentLanguage = normalized; - preferredLanguage = normalized; - document.documentElement.lang = normalized; - const select = document.getElementById('languageSelect'); - if (select && select.value !== normalized) { - select.value = normalized; - } - updateStaticTexts(); - if (userData?.user) { - renderUserData(); - } else { - renderTransactions(); - renderServerList(); - } - renderApps(); - updateActionButtons(); - } - - function setupLanguageSelector() { - const select = document.getElementById('languageSelect'); - if (!select) { - return; - } - select.addEventListener('change', event => { - applyLanguage(event.target.value); - }); - } function createError(title, message, status) { const error = new Error(message || title); @@ -980,17 +567,14 @@ try { const telegramUser = tg.initDataUnsafe?.user; if (telegramUser?.language_code) { - preferredLanguage = normalizeLanguage(telegramUser.language_code); - applyLanguage(preferredLanguage); - } else { - applyLanguage(currentLanguage); + preferredLanguage = telegramUser.language_code.split('-')[0]; } await loadAppsConfig(); const initData = tg.initData || ''; if (!initData) { - throw createError(t('errors.authorizationTitle'), t('errors.missingInitData')); + throw createError('Authorization Error', 'Missing Telegram init data'); } const response = await fetch('/miniapp/subscription', { @@ -1003,9 +587,9 @@ if (!response.ok) { let detail = response.status === 401 - ? t('errors.authorizationMessage') - : t('errors.defaultMessage'); - let title = response.status === 401 ? t('errors.authorizationTitle') : t('errors.defaultTitle'); + ? 'Authorization failed. Please open the mini app from Telegram.' + : 'Subscription not found'; + let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; try { const errorPayload = await response.json(); @@ -1021,24 +605,19 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; - userData.subscriptionCryptoLink = userData.subscription_crypto_link || userData.subscriptionCryptoLink || null; + userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; if (userData?.user?.language) { - preferredLanguage = normalizeLanguage(userData.user.language); - applyLanguage(preferredLanguage); - } else { - applyLanguage(currentLanguage); + preferredLanguage = userData.user.language; } - document.getElementById('errorState')?.classList.add('hidden'); - renderUserData(); detectPlatform(); setActivePlatformButton(); renderApps(); - document.getElementById('loadingState')?.classList.add('hidden'); - document.getElementById('mainContent')?.classList.remove('hidden'); + document.getElementById('loadingState').classList.add('hidden'); + document.getElementById('mainContent').classList.remove('hidden'); } catch (error) { console.error('Initialization error:', error); showError(error); @@ -1070,281 +649,38 @@ const fallbackName = rawName || [user.first_name, user.last_name].filter(Boolean).join(' ') || `User ${user.telegram_id || ''}`.trim(); const avatarChar = (fallbackName.replace(/^@/, '')[0] || 'U').toUpperCase(); - const avatarEl = document.getElementById('userAvatar'); - const nameEl = document.getElementById('userName'); - if (avatarEl) { - avatarEl.textContent = avatarChar; - } - if (nameEl) { - nameEl.textContent = fallbackName; - } + document.getElementById('userAvatar').textContent = avatarChar; + document.getElementById('userName').textContent = fallbackName; const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase(); const knownStatuses = ['active', 'expired', 'trial', 'disabled']; const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown'; const statusBadge = document.getElementById('statusBadge'); - if (statusBadge) { - const translatedStatus = t(`status.${statusClass}`); - statusBadge.textContent = translatedStatus || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); - statusBadge.className = `status-badge status-${statusClass}`; - } + statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); + statusBadge.className = `status-badge status-${statusClass}`; const expiresAt = user.expires_at ? new Date(user.expires_at) : null; let daysLeft = '—'; if (expiresAt && !Number.isNaN(expiresAt.getTime())) { const diffDays = Math.ceil((expiresAt - new Date()) / (1000 * 60 * 60 * 24)); - daysLeft = diffDays > 0 ? String(diffDays) : '0'; - } - const daysLeftEl = document.getElementById('daysLeft'); - if (daysLeftEl) { - daysLeftEl.textContent = daysLeft; + daysLeft = diffDays > 0 ? diffDays : '0'; } + document.getElementById('daysLeft').textContent = daysLeft; + document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime()) + ? expiresAt.toLocaleDateString() + : '—'; - const expiresEl = document.getElementById('expiresAt'); - if (expiresEl) { - expiresEl.textContent = formatDate(expiresAt); - } + const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0; + document.getElementById('serversCount').textContent = serversCount; - const trafficUsedEl = document.getElementById('trafficUsed'); - if (trafficUsedEl) { - trafficUsedEl.textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); - } + document.getElementById('trafficUsed').textContent = + user.traffic_used_label || formatTraffic(user.traffic_used_gb); + document.getElementById('trafficLimit').textContent = + user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); - const trafficLimitEl = document.getElementById('trafficLimit'); - if (trafficLimitEl) { - trafficLimitEl.textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); - } - - updateServerCount(); - renderBalance(); - renderTransactions(); - renderServerList(); updateActionButtons(); } - function renderBalance() { - const amountEl = document.getElementById('balanceAmount'); - const metaEl = document.getElementById('balanceMeta'); - if (!amountEl || !metaEl || !userData?.user) { - return; - } - const currency = getCurrencyCode(userData.user.balance_currency); - const balanceValue = getAmountInRubles(userData.user.balance_rubles, userData.user.balance_kopeks); - amountEl.textContent = formatCurrencyAmount(balanceValue, currency, { withSign: false }); - metaEl.textContent = `${t('balance.currencyLabel')} ${currency}`; - } - - function getAmountInRubles(amountRubles, amountKopeks) { - if (typeof amountRubles === 'number' && Number.isFinite(amountRubles)) { - return amountRubles; - } - const numericRubles = Number.parseFloat(amountRubles); - if (Number.isFinite(numericRubles)) { - return numericRubles; - } - const kopeks = Number.parseInt(amountKopeks ?? 0, 10); - if (Number.isFinite(kopeks)) { - return kopeks / 100; - } - return 0; - } - - function renderTransactions() { - const listEl = document.getElementById('transactionsList'); - const emptyEl = document.getElementById('transactionsEmpty'); - if (!listEl || !emptyEl) { - return; - } - - const transactions = Array.isArray(userData?.transactions) ? userData.transactions : []; - if (!transactions.length) { - listEl.innerHTML = ''; - emptyEl.classList.remove('hidden'); - return; - } - - emptyEl.classList.add('hidden'); - const currency = getCurrencyCode(userData?.user?.balance_currency); - - listEl.innerHTML = transactions.map(tx => { - const title = escapeHtml(getTransactionTitle(tx)); - const statusLabel = tx?.is_completed ? t('transactions.completed') : t('transactions.pending'); - const amountValue = getTransactionAmountValue(tx); - const amountClass = amountValue >= 0 ? 'positive' : 'negative'; - const amountText = escapeHtml(formatCurrencyAmount(amountValue, currency, { withSign: true })); - const metaParts = []; - const dateLabel = formatDateTime(tx?.created_at); - if (dateLabel) { - metaParts.push(`${t('transactions.meta.date')} ${dateLabel}`); - } - if (tx?.payment_method) { - metaParts.push(`${t('transactions.meta.payment')} ${tx.payment_method}`); - } - metaParts.push(`${t('transactions.meta.status')} ${statusLabel}`); - const metaHtml = metaParts.map(part => `${escapeHtml(part)}`).join(' • '); - - return ` -
        • -
          -
          ${title}
          -
          ${metaHtml}
          -
          -
          ${amountText}
          -
        • - `; - }).join(''); - } - - function getTransactionAmountValue(tx) { - if (!tx) { - return 0; - } - return getAmountInRubles(tx.amount_rubles, tx.amount_kopeks); - } - - function getTransactionTitle(tx) { - if (!tx) { - return t('transactions.fallbackTitle'); - } - const description = typeof tx.description === 'string' ? tx.description.trim() : ''; - if (description) { - return description; - } - const type = typeof tx.type === 'string' ? tx.type.toLowerCase() : ''; - const translated = t(`transactions.types.${type}`); - if (translated && translated !== `transactions.types.${type}`) { - return translated; - } - return t('transactions.fallbackTitle'); - } - - function formatDateTime(value) { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - try { - return date.toLocaleString(currentLanguage, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch (error) { - return date.toISOString().replace('T', ' ').split('.')[0]; - } - } - - function renderServerList() { - const listEl = document.getElementById('serversList'); - const emptyEl = document.getElementById('serversEmpty'); - if (!listEl || !emptyEl) { - return; - } - - const servers = getServerList(); - if (!servers.length) { - listEl.innerHTML = ''; - emptyEl.classList.remove('hidden'); - updateServerCount(); - return; - } - - emptyEl.classList.add('hidden'); - updateServerCount(); - - listEl.innerHTML = servers.map(server => { - const description = server.description ? `
          ${escapeHtml(server.description)}
          ` : ''; - return ` -
        • -
          -
          ${escapeHtml(server.name)}
          - ${description} -
          -
        • - `; - }).join(''); - } - - function getServerList() { - if (!userData) { - return []; - } - const items = []; - if (Array.isArray(userData.connected_squads) && userData.connected_squads.length) { - userData.connected_squads.forEach(item => { - const normalized = normalizeServer(item); - if (normalized) { - items.push(normalized); - } - }); - } - if (!items.length && Array.isArray(userData.links)) { - userData.links.forEach(link => { - const name = extractServerName(link); - if (name) { - items.push({ name, description: '' }); - } - }); - } - const seen = new Set(); - return items.filter(item => { - const key = `${item.name}|${item.description}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); - } - - function normalizeServer(item) { - if (!item) { - return null; - } - if (typeof item === 'string') { - const trimmed = item.trim(); - if (!trimmed) { - return null; - } - return { name: trimmed, description: '' }; - } - if (typeof item === 'object') { - const name = item.name || item.title || item.id || item.slug || ''; - const description = item.description || item.location || ''; - if (!name) { - return null; - } - return { name: String(name), description: description ? String(description) : '' }; - } - return null; - } - - function extractServerName(link) { - if (typeof link !== 'string') { - return ''; - } - try { - const url = new URL(link); - return url.hostname || link; - } catch (error) { - return link; - } - } - - function updateServerCount() { - const countEl = document.getElementById('serversCount'); - if (!countEl) { - return; - } - const count = getServerList().length; - countEl.textContent = String(count); - } - function detectPlatform() { const userAgent = navigator.userAgent.toLowerCase(); if (userAgent.includes('iphone') || userAgent.includes('ipad')) { @@ -1387,20 +723,19 @@ const apps = getAppsForCurrentPlatform(); if (!apps.length) { - container.innerHTML = `
          ${escapeHtml(t('installation.noApps'))}
          `; + container.innerHTML = '
          No installation guide available for this platform yet.
          '; return; } container.innerHTML = apps.map(app => { const iconChar = (app.name?.[0] || 'A').toUpperCase(); - const featuredBadge = app.isFeatured ? `${escapeHtml(t('installation.recommended'))}` : ''; - const appName = escapeHtml(app.name || t('installation.defaultAppName')); + const featuredBadge = app.isFeatured ? 'Recommended' : ''; return `
          -
          ${escapeHtml(iconChar)}
          +
          ${iconChar}
          -
          ${appName}
          +
          ${app.name || 'App'}
          ${featuredBadge}
          @@ -1421,7 +756,7 @@
          ${stepNum++} - ${escapeHtml(t('installation.steps.download'))} + Download & Install
          ${app.installationStep.description ? `
          ${getLocalizedText(app.installationStep.description)}
          ` : ''} ${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? ` @@ -1442,7 +777,7 @@
          ${stepNum++} - ${escapeHtml(t('installation.steps.add'))} + Add Subscription
          ${getLocalizedText(app.addSubscriptionStep.description)}
          @@ -1454,7 +789,7 @@
          ${stepNum++} - ${escapeHtml(t('installation.steps.connect'))} + Connect & Use
          ${getLocalizedText(app.connectAndUseStep.description)}
          @@ -1474,12 +809,9 @@ const telegramLang = tg.initDataUnsafe?.user?.language_code; const preferenceOrder = [ - currentLanguage, - currentLanguage?.split('-')[0], preferredLanguage, preferredLanguage?.split('-')[0], userData?.user?.language, - userData?.user?.language?.split('-')[0], telegramLang, telegramLang?.split('-')[0], 'en', @@ -1506,208 +838,110 @@ if (!Number.isFinite(numeric)) { return '0 GB'; } - const digits = numeric >= 100 ? 0 : numeric >= 10 ? 1 : 2; - try { - return `${new Intl.NumberFormat(currentLanguage, { - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }).format(numeric)} GB`; - } catch (error) { - return `${numeric.toFixed(digits)} GB`; + if (numeric >= 100) { + return `${numeric.toFixed(0)} GB`; } + if (numeric >= 10) { + return `${numeric.toFixed(1)} GB`; + } + return `${numeric.toFixed(2)} GB`; } function formatTrafficLimit(limit) { const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0'); if (!Number.isFinite(numeric) || numeric <= 0) { - return t('info.unlimited'); - } - try { - return `${new Intl.NumberFormat(currentLanguage, { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(numeric)} GB`; - } catch (error) { - return `${numeric.toFixed(0)} GB`; + return 'Unlimited'; } + return `${numeric.toFixed(0)} GB`; } - function formatCurrencyAmount(amount, currency, options = {}) { - const normalizedCurrency = getCurrencyCode(currency); - const value = typeof amount === 'number' ? amount : Number.parseFloat(amount ?? '0'); - const withSign = Boolean(options.withSign); - if (!Number.isFinite(value)) { - return withSign ? `+0.00 ${normalizedCurrency}` : `0.00 ${normalizedCurrency}`; - } - try { - return new Intl.NumberFormat(currentLanguage, { - style: 'currency', - currency: normalizedCurrency, - signDisplay: withSign ? 'always' : 'auto', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); - } catch (error) { - const sign = withSign && value >= 0 ? '+' : ''; - return `${sign}${value.toFixed(2)} ${normalizedCurrency}`; - } - } - - function getCurrencyCode(currency) { - const fallback = 'RUB'; - if (!currency || typeof currency !== 'string') { - return fallback; - } - const trimmed = currency.trim(); - return trimmed ? trimmed.toUpperCase() : fallback; - } - - function getCryptolinkRedirectUrl() { - if (!userData) { - return null; - } - const template = userData.happ_cryptolink_redirect_template || userData.happCryptolinkRedirectTemplate; - const cryptoLink = userData.subscriptionCryptoLink || userData.happ_crypto_link; - return buildRedirectUrl(template, cryptoLink); - } - - function buildRedirectUrl(template, target) { - if (!template || !target) { - return null; - } - const trimmed = template.trim(); - if (!trimmed) { - return null; - } - const encodedTarget = encodeURIComponent(target); - if (trimmed.includes('{url}')) { - return trimmed.replace('{url}', encodedTarget); - } - if (/[?&]$/.test(trimmed) || trimmed.endsWith('=')) { - return `${trimmed}${encodedTarget}`; - } - if (trimmed.includes('?')) { - return `${trimmed}&redirect_to=${encodedTarget}`; - } - return `${trimmed}?redirect_to=${encodedTarget}`; - } - - function getSubscriptionCopyLink() { - if (!userData) { - return ''; - } - return userData.subscriptionUrl - || userData.subscription_url - || userData.subscriptionCryptoLink - || userData.subscription_crypto_link - || ''; + function getCurrentSubscriptionUrl() { + return userData?.subscription_url || userData?.subscriptionUrl || ''; } function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); - const redirectUrl = getCryptolinkRedirectUrl(); - const copyLink = getSubscriptionCopyLink(); + const hasUrl = Boolean(getCurrentSubscriptionUrl()); if (connectBtn) { - connectBtn.disabled = !redirectUrl; - connectBtn.classList.toggle('hidden', !redirectUrl); + connectBtn.disabled = !hasUrl; } if (copyBtn) { - copyBtn.disabled = !copyLink || !navigator.clipboard; + copyBtn.disabled = !hasUrl || !navigator.clipboard; } } - function showPopup(message, title) { - const popupTitle = title || t('popup.defaultTitle'); + function showPopup(message, title = 'Info') { if (typeof tg.showPopup === 'function') { tg.showPopup({ - title: popupTitle, + title, message, buttons: [{ type: 'ok' }] }); } else { - alert(`${popupTitle}\n\n${message}`); + alert(message); } } + document.querySelectorAll('.platform-btn').forEach(btn => { + btn.addEventListener('click', () => { + currentPlatform = btn.dataset.platform; + setActivePlatformButton(); + renderApps(); + }); + }); + + document.getElementById('connectBtn')?.addEventListener('click', () => { + const subscriptionUrl = getCurrentSubscriptionUrl(); + if (!subscriptionUrl) { + return; + } + + const apps = getAppsForCurrentPlatform(); + const featuredApp = apps.find(app => app.isFeatured) || apps[0]; + + if (featuredApp?.urlScheme) { + window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`; + } else if (userData?.happ_link && featuredApp?.id === 'happ') { + window.location.href = userData.happ_link; + } else { + window.location.href = subscriptionUrl; + } + }); + + document.getElementById('copyBtn')?.addEventListener('click', async () => { + const subscriptionUrl = getCurrentSubscriptionUrl(); + if (!subscriptionUrl || !navigator.clipboard) { + return; + } + + try { + await navigator.clipboard.writeText(subscriptionUrl); + showPopup('Subscription link copied to clipboard', 'Copied'); + } catch (error) { + console.warn('Clipboard copy failed:', error); + showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed'); + } + }); + function showError(error) { - document.getElementById('loadingState')?.classList.add('hidden'); - document.getElementById('mainContent')?.classList.add('hidden'); + document.getElementById('loadingState').classList.add('hidden'); const titleElement = document.getElementById('errorTitle'); const textElement = document.getElementById('errorText'); if (titleElement) { - titleElement.textContent = error?.title || t('errors.defaultTitle'); + titleElement.textContent = error?.title || 'Subscription Not Found'; } if (textElement) { - textElement.textContent = error?.message || t('errors.defaultMessage'); + textElement.textContent = error?.message || 'Please contact support to activate your subscription'; } - document.getElementById('errorState')?.classList.remove('hidden'); + document.getElementById('errorState').classList.remove('hidden'); updateActionButtons(); } - function setupPlatformButtons() { - document.querySelectorAll('.platform-btn').forEach(btn => { - btn.addEventListener('click', () => { - currentPlatform = btn.dataset.platform; - setActivePlatformButton(); - renderApps(); - }); - }); - } - - document.getElementById('connectBtn')?.addEventListener('click', () => { - const redirectUrl = getCryptolinkRedirectUrl(); - if (!redirectUrl) { - return; - } - window.location.href = redirectUrl; - }); - - document.getElementById('copyBtn')?.addEventListener('click', async () => { - const copyLink = getSubscriptionCopyLink(); - if (!copyLink || !navigator.clipboard) { - return; - } - - try { - await navigator.clipboard.writeText(copyLink); - showPopup(t('copy.success'), t('copy.successTitle')); - } catch (error) { - console.warn('Clipboard copy failed:', error); - showPopup(t('copy.error'), t('copy.errorTitle')); - } - }); - - function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function formatDate(date) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return '—'; - } - try { - return date.toLocaleDateString(currentLanguage, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - } catch (error) { - return date.toISOString().split('T')[0]; - } - } - - setupLanguageSelector(); - setupPlatformButtons(); init(); From 20f83ac751a745253ea470e110b76be748114b63 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:14:40 +0300 Subject: [PATCH 07/40] feat: enhance miniapp UI with localization and account data --- miniapp/index.html | 1503 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 1341 insertions(+), 162 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index ba983c31..c3eefed9 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4,6 +4,7 @@ + VPN Subscription - +
          - -
          Secure & Fast Connection
          +
          + +
          + + +
          +
          +
          Secure & Fast Connection
          +
          + Locale: + en-US +
          -
          Loading your subscription...
          +
          Loading your subscription...
          @@ -467,47 +630,71 @@
          -
          -
          Days Left
          +
          Days Left
          -
          -
          Servers
          +
          Servers
          - Expires + Expires -
          - Traffic Used + Traffic Used -
          - Traffic Limit + Traffic Limit -
          + + + + + +
          -
          Installation Guide
          +
          Installation Guide
          @@ -524,7 +711,7 @@
          - + From 4e4e80f3c40189239fda2d3b78675c2a33678a0c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:16:38 +0300 Subject: [PATCH 08/40] Revert "Enhance miniapp page with localization and account data" --- miniapp/index.html | 1503 +++++--------------------------------------- 1 file changed, 162 insertions(+), 1341 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index c3eefed9..ba983c31 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4,7 +4,6 @@ - VPN Subscription - +
          -
          - -
          - - -
          -
          -
          Secure & Fast Connection
          -
          - Locale: - en-US -
          + +
          Secure & Fast Connection
          -
          Loading your subscription...
          +
          Loading your subscription...
          @@ -630,71 +467,47 @@
          -
          -
          Days Left
          +
          Days Left
          -
          -
          Servers
          +
          Servers
          - Expires + Expires -
          - Traffic Used + Traffic Used -
          - Traffic Limit + Traffic Limit -
          - - - - - -
          -
          Installation Guide
          +
          Installation Guide
          @@ -711,7 +524,7 @@
          - - From f420e07a24541b1c8e2a8e9d7d014c27228f963a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:17:09 +0300 Subject: [PATCH 09/40] Add localization and account data to miniapp --- app/webapi/routes/miniapp.py | 40 +- app/webapi/schemas/miniapp.py | 18 + miniapp/index.html | 784 ++++++++++++++++++++++++++++++---- 3 files changed, 752 insertions(+), 90 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5c4d596c..1cb0fb4d 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4,12 +4,14 @@ import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription +from app.database.models import Subscription, Transaction from app.services.subscription_service import SubscriptionService +from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( TelegramWebAppAuthError, parse_webapp_init_data, @@ -20,6 +22,7 @@ from ..schemas.miniapp import ( MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, + MiniAppTransaction, ) @@ -88,6 +91,21 @@ def _is_remnawave_configured() -> bool: return bool(params.get("base_url") and params.get("api_key")) +def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: + return MiniAppTransaction( + id=transaction.id, + type=transaction.type, + amount_kopeks=transaction.amount_kopeks, + amount_rubles=round(transaction.amount_kopeks / 100, 2), + description=transaction.description, + payment_method=transaction.payment_method, + external_id=transaction.external_id, + is_completed=transaction.is_completed, + created_at=transaction.created_at, + completed_at=transaction.completed_at, + ) + + async def _load_subscription_links( subscription: Subscription, ) -> Dict[str, Any]: @@ -165,10 +183,25 @@ async def get_subscription_details( or subscription.subscription_crypto_link ) + happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) + connected_squads: List[str] = list(subscription.connected_squads or []) links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} + transactions_query = ( + select(Transaction) + .where(Transaction.user_id == user.id) + .order_by(Transaction.created_at.desc()) + .limit(10) + ) + transactions_result = await db.execute(transactions_query) + transactions = list(transactions_result.scalars().all()) + + balance_currency = getattr(user, "balance_currency", None) + if isinstance(balance_currency, str): + balance_currency = balance_currency.upper() + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -209,5 +242,10 @@ async def get_subscription_details( happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), + happ_cryptolink_redirect_link=happ_redirect_link, + balance_kopeks=user.balance_kopeks, + balance_rubles=round(user.balance_rubles, 2), + balance_currency=balance_currency, + transactions=[_serialize_transaction(tx) for tx in transactions], ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 9c5a4308..7ce2b375 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -31,6 +31,19 @@ class MiniAppSubscriptionUser(BaseModel): has_active_subscription: bool = False +class MiniAppTransaction(BaseModel): + id: int + type: str + amount_kopeks: int + amount_rubles: float + description: Optional[str] = None + payment_method: Optional[str] = None + external_id: Optional[str] = None + is_completed: bool + created_at: datetime + completed_at: Optional[datetime] = None + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: int @@ -44,4 +57,9 @@ class MiniAppSubscriptionResponse(BaseModel): happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None + happ_cryptolink_redirect_link: Optional[str] = None + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + balance_currency: Optional[str] = None + transactions: List[MiniAppTransaction] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index ba983c31..7af22803 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -48,6 +48,29 @@ margin: 0 auto; } + .language-switcher { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; + } + + .language-select { + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: white; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + appearance: none; + } + + .language-select:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(36, 129, 204, 0.15); + } + /* Header */ .header { text-align: center; @@ -104,6 +127,109 @@ margin-bottom: 12px; } + .balance-summary { + display: flex; + align-items: baseline; + gap: 8px; + } + + .balance-amount { + font-size: 24px; + font-weight: 700; + color: var(--primary); + } + + .balance-currency { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + } + + .history-list, + .server-list { + list-style: none; + padding: 0; + margin: 0; + } + + .history-item { + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + } + + .history-item:last-child { + border-bottom: none; + } + + .history-item-main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; + } + + .history-amount { + font-size: 16px; + font-weight: 700; + } + + .history-amount.positive { + color: #0f5132; + } + + .history-amount.negative { + color: #842029; + } + + .history-type { + font-size: 14px; + font-weight: 600; + } + + .history-meta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .history-meta span + span::before { + content: '•'; + margin: 0 4px 0 0; + color: var(--border-color); + } + + .history-description { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; + line-height: 1.5; + } + + .empty-state { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + padding: 12px 0; + } + + .server-item { + padding: 12px; + background: white; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--border-color); + margin-bottom: 8px; + } + + .server-item:last-child { + margin-bottom: 0; + } + /* User Info */ .user-header { display: flex; @@ -433,23 +559,30 @@
          +
          + +
          +
          - -
          Secure & Fast Connection
          + +
          Secure & Fast Connection
          -
          Loading your subscription...
          +
          Loading your subscription...
          @@ -467,24 +600,24 @@
          -
          -
          Days Left
          +
          Days Left
          -
          -
          Servers
          +
          Servers
          - Expires + Expires -
          - Traffic Used + Traffic Used -
          - Traffic Limit + Traffic Limit -
          @@ -494,26 +627,49 @@ - Connect to VPN + Connect to VPN + +
          +
          Balance
          +
          +
          + +
          +
          + + +
          +
          Transaction History
          +
            + +
            + + +
            +
            Connected Servers
            +
              + +
              +
              -
              Installation Guide
              - +
              Installation Guide
              +
              - - - - + + + +
              @@ -547,10 +703,260 @@ }); } + const LANG_STORAGE_KEY = 'remnawave-miniapp-language'; + const SUPPORTED_LANGUAGES = ['en', 'ru']; + + const translations = { + en: { + 'app.title': 'VPN Subscription', + 'app.name': 'RemnaWave VPN', + 'app.subtitle': 'Secure & Fast Connection', + 'app.loading': 'Loading your subscription...', + 'error.default.title': 'Subscription Not Found', + 'error.default.message': 'Please contact support to activate your subscription.', + 'stats.days_left': 'Days left', + 'stats.servers': 'Servers', + 'info.expires': 'Expires', + 'info.traffic_used': 'Traffic used', + 'info.traffic_limit': 'Traffic limit', + 'button.connect.default': 'Connect to VPN', + 'button.connect.happ': 'Connect', + 'button.copy': 'Copy subscription link', + 'card.balance.title': 'Balance', + 'card.history.title': 'Transaction History', + 'card.servers.title': 'Connected Servers', + 'apps.title': 'Installation guide', + 'apps.no_data': 'No installation guide available for this platform yet.', + 'apps.featured': 'Recommended', + 'apps.step.download': 'Download & install', + 'apps.step.add': 'Add subscription', + 'apps.step.connect': 'Connect & use', + 'history.empty': 'No transactions yet', + 'history.status.completed': 'Completed', + 'history.status.pending': 'Processing', + 'history.type.deposit': 'Top-up', + 'history.type.withdrawal': 'Withdrawal', + 'history.type.subscription_payment': 'Subscription payment', + 'history.type.refund': 'Refund', + 'history.type.referral_reward': 'Referral reward', + 'servers.empty': 'No servers connected yet', + 'language.ariaLabel': 'Select interface language', + 'notifications.copy.success': 'Subscription link copied to clipboard.', + 'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.', + 'notifications.copy.title.success': 'Copied', + 'notifications.copy.title.failure': 'Copy failed', + 'status.active': 'Active', + 'status.trial': 'Trial', + 'status.expired': 'Expired', + 'status.disabled': 'Disabled', + 'status.unknown': 'Unknown', + 'platform.ios': 'iOS', + 'platform.android': 'Android', + 'platform.pc': 'PC', + 'platform.tv': 'TV', + 'units.gb': 'GB', + 'values.unlimited': 'Unlimited' + }, + ru: { + 'app.title': 'Подписка VPN', + 'app.name': 'RemnaWave VPN', + 'app.subtitle': 'Безопасное и быстрое подключение', + 'app.loading': 'Загружаем вашу подписку...', + 'error.default.title': 'Подписка не найдена', + 'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.', + 'stats.days_left': 'Осталось дней', + 'stats.servers': 'Серверы', + 'info.expires': 'Действует до', + 'info.traffic_used': 'Использовано трафика', + 'info.traffic_limit': 'Лимит трафика', + 'button.connect.default': 'Подключиться к VPN', + 'button.connect.happ': 'Подключиться', + 'button.copy': 'Скопировать ссылку подписки', + 'card.balance.title': 'Баланс', + 'card.history.title': 'История операций', + 'card.servers.title': 'Подключённые серверы', + 'apps.title': 'Инструкция по установке', + 'apps.no_data': 'Для этой платформы инструкция пока недоступна.', + 'apps.featured': 'Рекомендуем', + 'apps.step.download': 'Скачать и установить', + 'apps.step.add': 'Добавить подписку', + 'apps.step.connect': 'Подключиться и пользоваться', + 'history.empty': 'Операции ещё не проводились', + 'history.status.completed': 'Выполнено', + 'history.status.pending': 'Обрабатывается', + 'history.type.deposit': 'Пополнение', + 'history.type.withdrawal': 'Списание', + 'history.type.subscription_payment': 'Оплата подписки', + 'history.type.refund': 'Возврат', + 'history.type.referral_reward': 'Реферальное вознаграждение', + 'servers.empty': 'Подключённых серверов пока нет', + 'language.ariaLabel': 'Выберите язык интерфейса', + 'notifications.copy.success': 'Ссылка подписки скопирована.', + 'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.', + 'notifications.copy.title.success': 'Готово', + 'notifications.copy.title.failure': 'Ошибка копирования', + 'status.active': 'Активна', + 'status.trial': 'Пробная', + 'status.expired': 'Истекла', + 'status.disabled': 'Отключена', + 'status.unknown': 'Неизвестно', + 'platform.ios': 'iOS', + 'platform.android': 'Android', + 'platform.pc': 'ПК', + 'platform.tv': 'TV', + 'units.gb': 'ГБ', + 'values.unlimited': 'Безлимит' + } + }; + let userData = null; let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; + let languageLockedByUser = false; + let currentErrorState = null; + + function resolveLanguage(lang) { + if (!lang) { + return null; + } + const normalized = String(lang).toLowerCase(); + if (SUPPORTED_LANGUAGES.includes(normalized)) { + return normalized; + } + const short = normalized.split('-')[0]; + if (SUPPORTED_LANGUAGES.includes(short)) { + return short; + } + return null; + } + + function safeGetStoredLanguage() { + try { + return localStorage.getItem(LANG_STORAGE_KEY); + } catch (error) { + console.warn('Unable to access localStorage:', error); + return null; + } + } + + function safeSetStoredLanguage(lang) { + try { + localStorage.setItem(LANG_STORAGE_KEY, lang); + } catch (error) { + console.warn('Unable to persist language preference:', error); + } + } + + function t(key) { + const language = preferredLanguage || 'en'; + const chain = []; + if (translations[language]) { + chain.push(translations[language]); + } + const base = language.split('-')[0]; + if (translations[base] && !chain.includes(translations[base])) { + chain.push(translations[base]); + } + if (translations.en && !chain.includes(translations.en)) { + chain.push(translations.en); + } + for (const dict of chain) { + if (dict && Object.prototype.hasOwnProperty.call(dict, key)) { + return dict[key]; + } + } + return key; + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value ?? ''; + return div.innerHTML; + } + + function updateErrorTexts() { + const titleElement = document.getElementById('errorTitle'); + const textElement = document.getElementById('errorText'); + if (!titleElement || !textElement) { + return; + } + const title = currentErrorState?.title || t('error.default.title'); + const message = currentErrorState?.message || t('error.default.message'); + titleElement.textContent = title; + textElement.textContent = message; + } + + function applyTranslations() { + document.title = t('app.title'); + document.documentElement.setAttribute('lang', preferredLanguage); + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + if (!key) { + return; + } + element.textContent = t(key); + }); + const languageSelect = document.getElementById('languageSelect'); + if (languageSelect) { + languageSelect.value = preferredLanguage; + languageSelect.setAttribute('aria-label', t('language.ariaLabel')); + } + updateErrorTexts(); + } + + function updateConnectButtonLabel() { + const label = document.getElementById('connectBtnText'); + if (!label) { + return; + } + const useHappLabel = Boolean(userData?.happ_cryptolink_redirect_link); + const key = useHappLabel ? 'button.connect.happ' : 'button.connect.default'; + label.textContent = t(key); + } + + function refreshAfterLanguageChange() { + applyTranslations(); + if (userData) { + renderUserData(); + } else { + updateConnectButtonLabel(); + } + renderApps(); + updateActionButtons(); + } + + function setLanguage(language, options = {}) { + const persist = Boolean(options.persist); + const resolved = resolveLanguage(language) || preferredLanguage; + if (!persist && languageLockedByUser && resolved !== preferredLanguage) { + return; + } + preferredLanguage = resolved; + if (persist) { + languageLockedByUser = true; + safeSetStoredLanguage(preferredLanguage); + } + refreshAfterLanguageChange(); + } + + const storedLanguage = resolveLanguage(safeGetStoredLanguage()); + if (storedLanguage) { + preferredLanguage = storedLanguage; + languageLockedByUser = true; + } else { + const telegramLanguage = resolveLanguage(tg.initDataUnsafe?.user?.language_code); + if (telegramLanguage) { + preferredLanguage = telegramLanguage; + } + } + + applyTranslations(); + updateConnectButtonLabel(); + + document.getElementById('languageSelect')?.addEventListener('change', event => { + setLanguage(event.target.value, { persist: true }); + }); function createError(title, message, status) { const error = new Error(message || title); @@ -566,8 +972,13 @@ async function init() { try { const telegramUser = tg.initDataUnsafe?.user; - if (telegramUser?.language_code) { - preferredLanguage = telegramUser.language_code.split('-')[0]; + if (telegramUser?.language_code && !languageLockedByUser) { + const resolved = resolveLanguage(telegramUser.language_code); + if (resolved) { + preferredLanguage = resolved; + applyTranslations(); + updateConnectButtonLabel(); + } } await loadAppsConfig(); @@ -607,14 +1018,14 @@ userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; - if (userData?.user?.language) { - preferredLanguage = userData.user.language; + const responseLanguage = resolveLanguage(userData?.user?.language); + if (responseLanguage && !languageLockedByUser) { + preferredLanguage = responseLanguage; } - renderUserData(); detectPlatform(); setActivePlatformButton(); - renderApps(); + refreshAfterLanguageChange(); document.getElementById('loadingState').classList.add('hidden'); document.getElementById('mainContent').classList.remove('hidden'); @@ -646,17 +1057,23 @@ const user = userData.user; const rawName = user.display_name || user.username || ''; - const fallbackName = rawName || [user.first_name, user.last_name].filter(Boolean).join(' ') || `User ${user.telegram_id || ''}`.trim(); + const fallbackName = rawName + || [user.first_name, user.last_name].filter(Boolean).join(' ') + || `User ${user.telegram_id || ''}`.trim(); const avatarChar = (fallbackName.replace(/^@/, '')[0] || 'U').toUpperCase(); document.getElementById('userAvatar').textContent = avatarChar; document.getElementById('userName').textContent = fallbackName; + const knownStatuses = ['active', 'trial', 'expired', 'disabled']; const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase(); - const knownStatuses = ['active', 'expired', 'trial', 'disabled']; const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown'; const statusBadge = document.getElementById('statusBadge'); - statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1); + const statusKey = `status.${statusClass}`; + const statusLabel = t(statusKey); + statusBadge.textContent = statusLabel === statusKey + ? (user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1)) + : statusLabel; statusBadge.className = `status-badge status-${statusClass}`; const expiresAt = user.expires_at ? new Date(user.expires_at) : null; @@ -666,11 +1083,13 @@ daysLeft = diffDays > 0 ? diffDays : '0'; } document.getElementById('daysLeft').textContent = daysLeft; - document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime()) - ? expiresAt.toLocaleDateString() - : '—'; + document.getElementById('expiresAt').textContent = formatDate(user.expires_at); - const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0; + const serversCount = Array.isArray(userData.connected_squads) + ? userData.connected_squads.length + : Array.isArray(userData.links) + ? userData.links.length + : 0; document.getElementById('serversCount').textContent = serversCount; document.getElementById('trafficUsed').textContent = @@ -678,6 +1097,10 @@ document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + renderBalanceSection(); + renderTransactionHistory(); + renderServersList(); + updateConnectButtonLabel(); updateActionButtons(); } @@ -723,19 +1146,22 @@ const apps = getAppsForCurrentPlatform(); if (!apps.length) { - container.innerHTML = '
              No installation guide available for this platform yet.
              '; + container.innerHTML = `
              ${escapeHtml(t('apps.no_data'))}
              `; return; } container.innerHTML = apps.map(app => { const iconChar = (app.name?.[0] || 'A').toUpperCase(); - const featuredBadge = app.isFeatured ? 'Recommended' : ''; + const appName = escapeHtml(app.name || 'App'); + const featuredBadge = app.isFeatured + ? `${escapeHtml(t('apps.featured'))}` + : ''; return `
              -
              ${iconChar}
              +
              ${escapeHtml(iconChar)}
              -
              ${app.name || 'App'}
              +
              ${appName}
              ${featuredBadge}
              @@ -752,22 +1178,27 @@ let stepNum = 1; if (app.installationStep) { + const descriptionHtml = app.installationStep.description + ? `
              ${getLocalizedText(app.installationStep.description)}
              ` + : ''; + const buttonsHtml = Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length + ? ` +
              + ${app.installationStep.buttons.map(btn => { + const buttonText = escapeHtml(getLocalizedText(btn.buttonText)); + return `${buttonText}`; + }).join('')} +
              + ` + : ''; html += `
              ${stepNum++} - Download & Install + ${escapeHtml(t('apps.step.download'))}
              - ${app.installationStep.description ? `
              ${getLocalizedText(app.installationStep.description)}
              ` : ''} - ${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? ` -
              - ${app.installationStep.buttons.map(btn => ` - - ${getLocalizedText(btn.buttonText)} - - `).join('')} -
              - ` : ''} + ${descriptionHtml} + ${buttonsHtml}
              `; } @@ -777,7 +1208,7 @@
              ${stepNum++} - Add Subscription + ${escapeHtml(t('apps.step.add'))}
              ${getLocalizedText(app.addSubscriptionStep.description)}
              @@ -789,7 +1220,7 @@
              ${stepNum++} - Connect & Use + ${escapeHtml(t('apps.step.connect'))}
              ${getLocalizedText(app.connectAndUseStep.description)}
              @@ -836,38 +1267,227 @@ function formatTraffic(value) { const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0'); if (!Number.isFinite(numeric)) { - return '0 GB'; + return `0 ${t('units.gb')}`; } if (numeric >= 100) { - return `${numeric.toFixed(0)} GB`; + return `${numeric.toFixed(0)} ${t('units.gb')}`; } if (numeric >= 10) { - return `${numeric.toFixed(1)} GB`; + return `${numeric.toFixed(1)} ${t('units.gb')}`; } - return `${numeric.toFixed(2)} GB`; + return `${numeric.toFixed(2)} ${t('units.gb')}`; } function formatTrafficLimit(limit) { const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0'); if (!Number.isFinite(numeric) || numeric <= 0) { - return 'Unlimited'; + return t('values.unlimited'); } - return `${numeric.toFixed(0)} GB`; + return `${numeric.toFixed(0)} ${t('units.gb')}`; + } + + function formatCurrency(value, currency = 'RUB') { + const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0'); + if (!Number.isFinite(numeric)) { + return `0 ${currency}`; + } + try { + return new Intl.NumberFormat(preferredLanguage, { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(numeric); + } catch (error) { + return `${numeric.toFixed(2)} ${currency}`; + } + } + + function formatDate(value) { + if (!value) { + return '—'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '—'; + } + try { + return new Intl.DateTimeFormat(preferredLanguage, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + } catch (error) { + return date.toLocaleDateString(); + } + } + + function formatDateTime(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + try { + return new Intl.DateTimeFormat(preferredLanguage, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); + } catch (error) { + return date.toLocaleString(); + } + } + + function renderBalanceSection() { + const amountElement = document.getElementById('balanceAmount'); + const currencyElement = document.getElementById('balanceCurrency'); + if (!amountElement) { + return; + } + const balanceRubles = typeof userData?.balance_rubles === 'number' + ? userData.balance_rubles + : Number.parseFloat(userData?.balance_rubles ?? '0'); + const currency = (userData?.balance_currency || 'RUB').toUpperCase(); + amountElement.textContent = formatCurrency(balanceRubles, currency); + if (currencyElement) { + if (userData?.balance_currency) { + currencyElement.textContent = currency; + currencyElement.classList.remove('hidden'); + } else { + currencyElement.textContent = ''; + currencyElement.classList.add('hidden'); + } + } + } + + function renderTransactionHistory() { + const list = document.getElementById('historyList'); + const emptyState = document.getElementById('historyEmpty'); + if (!list || !emptyState) { + return; + } + + const transactions = Array.isArray(userData?.transactions) ? userData.transactions : []; + if (!transactions.length) { + list.innerHTML = ''; + emptyState.textContent = t('history.empty'); + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + const currency = (userData?.balance_currency || 'RUB').toUpperCase(); + const negativeTypes = new Set(['withdrawal', 'subscription_payment']); + + const itemsHtml = transactions.map(tx => { + const type = (tx.type || '').toLowerCase(); + const typeKey = `history.type.${type}`; + const typeLabelRaw = t(typeKey); + const typeLabel = typeLabelRaw === typeKey ? (tx.type || type || '').replace(/_/g, ' ') : typeLabelRaw; + const amountRaw = typeof tx.amount_kopeks === 'number' + ? tx.amount_kopeks + : Number.parseInt(tx.amount_kopeks ?? '0', 10); + const amountValue = Number.isFinite(amountRaw) ? amountRaw / 100 : 0; + const isNegative = amountValue < 0 || negativeTypes.has(type); + const amountFormatted = `${isNegative ? '−' : '+'}${formatCurrency(Math.abs(amountValue), currency)}`; + const statusKey = tx.is_completed ? 'history.status.completed' : 'history.status.pending'; + const statusLabel = t(statusKey); + const metaParts = []; + const createdAt = formatDateTime(tx.created_at); + if (createdAt) { + metaParts.push(`${escapeHtml(createdAt)}`); + } + if (statusLabel) { + metaParts.push(`${escapeHtml(statusLabel)}`); + } + const metaHtml = metaParts.length ? `
              ${metaParts.join('')}
              ` : ''; + const descriptionHtml = tx.description + ? `
              ${escapeHtml(tx.description)}
              ` + : ''; + const amountClass = isNegative ? 'history-amount negative' : 'history-amount positive'; + + return ` +
            • +
              + ${amountFormatted} + ${escapeHtml(typeLabel)} +
              + ${metaHtml} + ${descriptionHtml} +
            • + `; + }).join(''); + + list.innerHTML = itemsHtml; + } + + function renderServersList() { + const list = document.getElementById('serversList'); + const emptyState = document.getElementById('serversEmpty'); + if (!list || !emptyState) { + return; + } + + const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : []; + if (!servers.length) { + list.innerHTML = ''; + emptyState.textContent = t('servers.empty'); + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + list.innerHTML = servers.map(server => `
            • ${escapeHtml(server)}
            • `).join(''); } function getCurrentSubscriptionUrl() { return userData?.subscription_url || userData?.subscriptionUrl || ''; } + function getConnectLink() { + if (!userData) { + return null; + } + + if (userData.happ_cryptolink_redirect_link) { + return userData.happ_cryptolink_redirect_link; + } + + const subscriptionUrl = getCurrentSubscriptionUrl(); + if (!subscriptionUrl) { + return null; + } + + const apps = getAppsForCurrentPlatform(); + const featuredApp = apps.find(app => app.isFeatured) || apps[0]; + + if (featuredApp?.urlScheme) { + return `${featuredApp.urlScheme}${subscriptionUrl}`; + } + if (userData?.happ_link && featuredApp?.id === 'happ') { + return userData.happ_link; + } + return subscriptionUrl; + } + function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); - const hasUrl = Boolean(getCurrentSubscriptionUrl()); + const connectLink = getConnectLink(); if (connectBtn) { - connectBtn.disabled = !hasUrl; + const hasConnect = Boolean(connectLink); + connectBtn.disabled = !hasConnect; + connectBtn.classList.toggle('hidden', !hasConnect); } + + const subscriptionUrl = getCurrentSubscriptionUrl(); if (copyBtn) { + const hasUrl = Boolean(subscriptionUrl); copyBtn.disabled = !hasUrl || !navigator.clipboard; } } @@ -884,29 +1504,31 @@ } } + function showError(error) { + document.getElementById('loadingState').classList.add('hidden'); + document.getElementById('mainContent').classList.add('hidden'); + currentErrorState = { + title: error?.title, + message: error?.message, + }; + updateErrorTexts(); + document.getElementById('errorState').classList.remove('hidden'); + updateActionButtons(); + } + document.querySelectorAll('.platform-btn').forEach(btn => { btn.addEventListener('click', () => { currentPlatform = btn.dataset.platform; setActivePlatformButton(); renderApps(); + updateActionButtons(); }); }); document.getElementById('connectBtn')?.addEventListener('click', () => { - const subscriptionUrl = getCurrentSubscriptionUrl(); - if (!subscriptionUrl) { - return; - } - - const apps = getAppsForCurrentPlatform(); - const featuredApp = apps.find(app => app.isFeatured) || apps[0]; - - if (featuredApp?.urlScheme) { - window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`; - } else if (userData?.happ_link && featuredApp?.id === 'happ') { - window.location.href = userData.happ_link; - } else { - window.location.href = subscriptionUrl; + const link = getConnectLink(); + if (link) { + window.location.href = link; } }); @@ -918,31 +1540,15 @@ try { await navigator.clipboard.writeText(subscriptionUrl); - showPopup('Subscription link copied to clipboard', 'Copied'); + showPopup(t('notifications.copy.success'), t('notifications.copy.title.success')); } catch (error) { console.warn('Clipboard copy failed:', error); - showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed'); + showPopup(t('notifications.copy.failure'), t('notifications.copy.title.failure')); } }); - function showError(error) { - document.getElementById('loadingState').classList.add('hidden'); - - const titleElement = document.getElementById('errorTitle'); - const textElement = document.getElementById('errorText'); - - if (titleElement) { - titleElement.textContent = error?.title || 'Subscription Not Found'; - } - if (textElement) { - textElement.textContent = error?.message || 'Please contact support to activate your subscription'; - } - - document.getElementById('errorState').classList.remove('hidden'); - updateActionButtons(); - } - init(); + From 3fdc1fdeda3df570416dace05499b1a9f2991a99 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:44:05 +0300 Subject: [PATCH 10/40] Enhance miniapp subscription details --- app/webapi/routes/miniapp.py | 112 +++++++++++++++++++++++++++++++- app/webapi/schemas/miniapp.py | 28 ++++++++ miniapp/index.html | 117 +++++++++++++++++++++++++++++++--- 3 files changed, 248 insertions(+), 9 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 1cb0fb4d..c35c8c11 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,13 +1,14 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.user import get_user_by_telegram_id from app.database.models import Subscription, Transaction from app.services.subscription_service import SubscriptionService @@ -19,6 +20,7 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( + MiniAppConnectedDevices, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -134,6 +136,88 @@ async def _load_subscription_links( return payload +async def _load_connected_servers( + db: AsyncSession, + squad_uuids: List[str], +) -> List[Dict[str, str]]: + if not squad_uuids: + return [] + + servers: List[Dict[str, str]] = [] + seen: Set[str] = set() + + for raw_uuid in squad_uuids: + if not isinstance(raw_uuid, str): + continue + + uuid = raw_uuid.strip() + if not uuid or uuid in seen: + continue + seen.add(uuid) + + display_name = uuid + + try: + server = await get_server_squad_by_uuid(db, uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load server squad %s: %s", uuid, error) + server = None + + if server: + display_name = ( + getattr(server, "display_name", None) + or getattr(server, "original_name", None) + or uuid + ) + + servers.append({"uuid": uuid, "display_name": display_name}) + + return servers + + +async def _load_connected_devices_info(user) -> MiniAppConnectedDevices: + connected = MiniAppConnectedDevices(count=None, limit=None) + + subscription = getattr(user, "subscription", None) + if subscription is not None: + connected.limit = getattr(subscription, "device_limit", None) + + if not getattr(user, "remnawave_uuid", None): + return connected + + if not _is_remnawave_configured(): + return connected + + try: + service = SubscriptionService() + async with service.api as api: + devices_info = await api.get_user_devices(user.remnawave_uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning( + "Failed to load connected devices for user %s: %s", + getattr(user, "telegram_id", "unknown"), + error, + ) + return connected + + if not devices_info: + connected.count = 0 + return connected + + total = devices_info.get("total") + if total is None: + devices = devices_info.get("devices") + if isinstance(devices, list): + total = len(devices) + + try: + connected.count = int(total) if total is not None else None + except (TypeError, ValueError): # pragma: no cover - defensive logging + connected.count = None + + return connected + + @router.post("/subscription", response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, @@ -186,6 +270,10 @@ async def get_subscription_details( happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads: List[str] = list(subscription.connected_squads or []) + connected_servers = await _load_connected_servers(db, connected_squads) + connected_devices = await _load_connected_devices_info(user) + if connected_devices.limit is None: + connected_devices.limit = subscription.device_limit links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} @@ -202,6 +290,21 @@ async def get_subscription_details( if isinstance(balance_currency, str): balance_currency = balance_currency.upper() + promo_group_payload = None + if user.promo_group: + promo_group_payload = { + "id": user.promo_group.id, + "name": user.promo_group.name, + "server_discount_percent": user.promo_group.server_discount_percent, + "traffic_discount_percent": user.promo_group.traffic_discount_percent, + "device_discount_percent": user.promo_group.device_discount_percent, + "apply_discounts_to_addons": getattr( + user.promo_group, + "apply_discounts_to_addons", + True, + ), + } + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -228,6 +331,9 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, + is_trial=bool(subscription.is_trial), + subscription_type="trial" if subscription.is_trial else "paid", + promo_group=promo_group_payload, ) return MiniAppSubscriptionResponse( @@ -239,6 +345,8 @@ async def get_subscription_details( links=links, ss_conf_links=ss_conf_links, connected_squads=connected_squads, + connected_servers=connected_servers, + connected_devices=connected_devices, happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), @@ -247,5 +355,7 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], + autopay_enabled=bool(subscription.autopay_enabled), + autopay_days_before=subscription.autopay_days_before, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 7ce2b375..18d9f144 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -10,6 +10,25 @@ class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") +class MiniAppPromoGroup(BaseModel): + id: int + name: str + server_discount_percent: int + traffic_discount_percent: int + device_discount_percent: int + apply_discounts_to_addons: bool = True + + +class MiniAppConnectedDevices(BaseModel): + count: Optional[int] = None + limit: Optional[int] = None + + +class MiniAppServerInfo(BaseModel): + uuid: str + display_name: str + + class MiniAppSubscriptionUser(BaseModel): telegram_id: int username: Optional[str] = None @@ -29,6 +48,9 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False + is_trial: bool = False + subscription_type: str = "paid" + promo_group: Optional[MiniAppPromoGroup] = None class MiniAppTransaction(BaseModel): @@ -54,6 +76,10 @@ class MiniAppSubscriptionResponse(BaseModel): links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) + connected_servers: List[MiniAppServerInfo] = Field(default_factory=list) + connected_devices: MiniAppConnectedDevices = Field( + default_factory=MiniAppConnectedDevices + ) happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None @@ -62,4 +88,6 @@ class MiniAppSubscriptionResponse(BaseModel): balance_rubles: float = 0.0 balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) + autopay_enabled: bool = False + autopay_days_before: Optional[int] = None diff --git a/miniapp/index.html b/miniapp/index.html index 7af22803..ecb72b5d 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -302,7 +302,7 @@ /* Stats Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 12px; } @@ -606,6 +606,10 @@
              -
              Servers
              +
              +
              -
              +
              Devices
              +
              @@ -620,6 +624,18 @@ Traffic Limit -
              +
              + Subscription Type + - +
              +
              + Autopay + - +
              +
              + Promo Group + - +
              @@ -716,9 +732,13 @@ 'error.default.message': 'Please contact support to activate your subscription.', 'stats.days_left': 'Days left', 'stats.servers': 'Servers', + 'stats.devices': 'Devices', 'info.expires': 'Expires', 'info.traffic_used': 'Traffic used', 'info.traffic_limit': 'Traffic limit', + 'info.subscription_type': 'Subscription type', + 'info.autopay_status': 'Autopay', + 'info.promo_group': 'Promo group', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -750,6 +770,11 @@ 'status.expired': 'Expired', 'status.disabled': 'Disabled', 'status.unknown': 'Unknown', + 'subscriptions.type.trial': 'Trial', + 'subscriptions.type.paid': 'Paid', + 'autopay.enabled': 'Enabled', + 'autopay.disabled': 'Disabled', + 'autopay.days_before_suffix': 'days before renewal', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'PC', @@ -766,9 +791,13 @@ 'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.', 'stats.days_left': 'Осталось дней', 'stats.servers': 'Серверы', + 'stats.devices': 'Устройства', 'info.expires': 'Действует до', 'info.traffic_used': 'Использовано трафика', 'info.traffic_limit': 'Лимит трафика', + 'info.subscription_type': 'Тип подписки', + 'info.autopay_status': 'Автоплатеж', + 'info.promo_group': 'Промогруппа', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -800,6 +829,11 @@ 'status.expired': 'Истекла', 'status.disabled': 'Отключена', 'status.unknown': 'Неизвестно', + 'subscriptions.type.trial': 'Триал', + 'subscriptions.type.paid': 'Платная', + 'autopay.enabled': 'Включен', + 'autopay.disabled': 'Выключен', + 'autopay.days_before_suffix': 'дн. до списания', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'ПК', @@ -1085,18 +1119,72 @@ document.getElementById('daysLeft').textContent = daysLeft; document.getElementById('expiresAt').textContent = formatDate(user.expires_at); - const serversCount = Array.isArray(userData.connected_squads) - ? userData.connected_squads.length - : Array.isArray(userData.links) - ? userData.links.length - : 0; + const connectedServers = Array.isArray(userData.connected_servers) + ? userData.connected_servers + : []; + const serversCount = connectedServers.length + || (Array.isArray(userData.connected_squads) + ? userData.connected_squads.length + : Array.isArray(userData.links) + ? userData.links.length + : 0); document.getElementById('serversCount').textContent = serversCount; + const connectedDevices = userData.connected_devices || {}; + const deviceLimitRaw = connectedDevices.limit ?? user.device_limit; + const deviceLimit = Number.isFinite(Number(deviceLimitRaw)) + ? Number(deviceLimitRaw) + : null; + const devicesCount = Number.isFinite(Number(connectedDevices.count)) + ? Number(connectedDevices.count) + : null; + let devicesText = '—'; + if (devicesCount !== null && deviceLimit !== null) { + devicesText = `${devicesCount}/${deviceLimit}`; + } else if (devicesCount !== null) { + devicesText = String(devicesCount); + } else if (deviceLimit !== null) { + devicesText = `—/${deviceLimit}`; + } + document.getElementById('devicesUsage').textContent = devicesText; + document.getElementById('trafficUsed').textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + const subscriptionTypeElement = document.getElementById('subscriptionType'); + if (subscriptionTypeElement) { + const typeKey = user.subscription_type + ? `subscriptions.type.${user.subscription_type}` + : user.is_trial + ? 'subscriptions.type.trial' + : 'subscriptions.type.paid'; + const typeLabel = t(typeKey); + subscriptionTypeElement.textContent = + typeLabel === typeKey + ? (user.subscription_type || (user.is_trial ? t('subscriptions.type.trial') : t('subscriptions.type.paid'))) + : typeLabel; + } + + const autopayStatusElement = document.getElementById('autopayStatus'); + if (autopayStatusElement) { + const autopayEnabled = userData.autopay_enabled === true; + const baseLabel = t(autopayEnabled ? 'autopay.enabled' : 'autopay.disabled'); + const autopayDaysRaw = Number(userData.autopay_days_before); + const hasAutopayDays = autopayEnabled && Number.isFinite(autopayDaysRaw) && autopayDaysRaw > 0; + autopayStatusElement.textContent = hasAutopayDays + ? `${baseLabel} • ${autopayDaysRaw} ${t('autopay.days_before_suffix')}` + : baseLabel; + } + + const promoGroupElement = document.getElementById('promoGroup'); + if (promoGroupElement) { + promoGroupElement.textContent = (user.promo_group && user.promo_group.name) + ? user.promo_group.name + : '—'; + } + renderBalanceSection(); renderTransactionHistory(); renderServersList(); @@ -1432,7 +1520,13 @@ return; } - const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : []; + const fallbackServers = Array.isArray(userData?.connected_squads) + ? userData.connected_squads.map(uuid => ({ uuid, display_name: uuid })) + : []; + const servers = Array.isArray(userData?.connected_servers) && userData.connected_servers.length + ? userData.connected_servers + : fallbackServers; + if (!servers.length) { list.innerHTML = ''; emptyState.textContent = t('servers.empty'); @@ -1441,7 +1535,14 @@ } emptyState.classList.add('hidden'); - list.innerHTML = servers.map(server => `
            • ${escapeHtml(server)}
            • `).join(''); + list.innerHTML = servers + .map(server => { + const name = typeof server === 'string' + ? server + : server?.display_name || server?.uuid || ''; + return `
            • ${escapeHtml(name)}
            • `; + }) + .join(''); } function getCurrentSubscriptionUrl() { From 68f72a88a4a6c6f8ffdd7a37582020bad15ece11 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:45:28 +0300 Subject: [PATCH 11/40] Revert "Improve miniapp subscription metadata display" --- app/webapi/routes/miniapp.py | 112 +------------------------------- app/webapi/schemas/miniapp.py | 28 -------- miniapp/index.html | 117 +++------------------------------- 3 files changed, 9 insertions(+), 248 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index c35c8c11..1cb0fb4d 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,14 +1,13 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.user import get_user_by_telegram_id from app.database.models import Subscription, Transaction from app.services.subscription_service import SubscriptionService @@ -20,7 +19,6 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( - MiniAppConnectedDevices, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -136,88 +134,6 @@ async def _load_subscription_links( return payload -async def _load_connected_servers( - db: AsyncSession, - squad_uuids: List[str], -) -> List[Dict[str, str]]: - if not squad_uuids: - return [] - - servers: List[Dict[str, str]] = [] - seen: Set[str] = set() - - for raw_uuid in squad_uuids: - if not isinstance(raw_uuid, str): - continue - - uuid = raw_uuid.strip() - if not uuid or uuid in seen: - continue - seen.add(uuid) - - display_name = uuid - - try: - server = await get_server_squad_by_uuid(db, uuid) - except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load server squad %s: %s", uuid, error) - server = None - - if server: - display_name = ( - getattr(server, "display_name", None) - or getattr(server, "original_name", None) - or uuid - ) - - servers.append({"uuid": uuid, "display_name": display_name}) - - return servers - - -async def _load_connected_devices_info(user) -> MiniAppConnectedDevices: - connected = MiniAppConnectedDevices(count=None, limit=None) - - subscription = getattr(user, "subscription", None) - if subscription is not None: - connected.limit = getattr(subscription, "device_limit", None) - - if not getattr(user, "remnawave_uuid", None): - return connected - - if not _is_remnawave_configured(): - return connected - - try: - service = SubscriptionService() - async with service.api as api: - devices_info = await api.get_user_devices(user.remnawave_uuid) - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to load connected devices for user %s: %s", - getattr(user, "telegram_id", "unknown"), - error, - ) - return connected - - if not devices_info: - connected.count = 0 - return connected - - total = devices_info.get("total") - if total is None: - devices = devices_info.get("devices") - if isinstance(devices, list): - total = len(devices) - - try: - connected.count = int(total) if total is not None else None - except (TypeError, ValueError): # pragma: no cover - defensive logging - connected.count = None - - return connected - - @router.post("/subscription", response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, @@ -270,10 +186,6 @@ async def get_subscription_details( happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads: List[str] = list(subscription.connected_squads or []) - connected_servers = await _load_connected_servers(db, connected_squads) - connected_devices = await _load_connected_devices_info(user) - if connected_devices.limit is None: - connected_devices.limit = subscription.device_limit links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} @@ -290,21 +202,6 @@ async def get_subscription_details( if isinstance(balance_currency, str): balance_currency = balance_currency.upper() - promo_group_payload = None - if user.promo_group: - promo_group_payload = { - "id": user.promo_group.id, - "name": user.promo_group.name, - "server_discount_percent": user.promo_group.server_discount_percent, - "traffic_discount_percent": user.promo_group.traffic_discount_percent, - "device_discount_percent": user.promo_group.device_discount_percent, - "apply_discounts_to_addons": getattr( - user.promo_group, - "apply_discounts_to_addons", - True, - ), - } - response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -331,9 +228,6 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, - is_trial=bool(subscription.is_trial), - subscription_type="trial" if subscription.is_trial else "paid", - promo_group=promo_group_payload, ) return MiniAppSubscriptionResponse( @@ -345,8 +239,6 @@ async def get_subscription_details( links=links, ss_conf_links=ss_conf_links, connected_squads=connected_squads, - connected_servers=connected_servers, - connected_devices=connected_devices, happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), @@ -355,7 +247,5 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], - autopay_enabled=bool(subscription.autopay_enabled), - autopay_days_before=subscription.autopay_days_before, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 18d9f144..7ce2b375 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -10,25 +10,6 @@ class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") -class MiniAppPromoGroup(BaseModel): - id: int - name: str - server_discount_percent: int - traffic_discount_percent: int - device_discount_percent: int - apply_discounts_to_addons: bool = True - - -class MiniAppConnectedDevices(BaseModel): - count: Optional[int] = None - limit: Optional[int] = None - - -class MiniAppServerInfo(BaseModel): - uuid: str - display_name: str - - class MiniAppSubscriptionUser(BaseModel): telegram_id: int username: Optional[str] = None @@ -48,9 +29,6 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False - is_trial: bool = False - subscription_type: str = "paid" - promo_group: Optional[MiniAppPromoGroup] = None class MiniAppTransaction(BaseModel): @@ -76,10 +54,6 @@ class MiniAppSubscriptionResponse(BaseModel): links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) - connected_servers: List[MiniAppServerInfo] = Field(default_factory=list) - connected_devices: MiniAppConnectedDevices = Field( - default_factory=MiniAppConnectedDevices - ) happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None @@ -88,6 +62,4 @@ class MiniAppSubscriptionResponse(BaseModel): balance_rubles: float = 0.0 balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) - autopay_enabled: bool = False - autopay_days_before: Optional[int] = None diff --git a/miniapp/index.html b/miniapp/index.html index ecb72b5d..7af22803 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -302,7 +302,7 @@ /* Stats Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 12px; } @@ -606,10 +606,6 @@
              -
              Servers
              -
              -
              -
              -
              Devices
              -
              @@ -624,18 +620,6 @@ Traffic Limit -
              -
              - Subscription Type - - -
              -
              - Autopay - - -
              -
              - Promo Group - - -
              @@ -732,13 +716,9 @@ 'error.default.message': 'Please contact support to activate your subscription.', 'stats.days_left': 'Days left', 'stats.servers': 'Servers', - 'stats.devices': 'Devices', 'info.expires': 'Expires', 'info.traffic_used': 'Traffic used', 'info.traffic_limit': 'Traffic limit', - 'info.subscription_type': 'Subscription type', - 'info.autopay_status': 'Autopay', - 'info.promo_group': 'Promo group', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -770,11 +750,6 @@ 'status.expired': 'Expired', 'status.disabled': 'Disabled', 'status.unknown': 'Unknown', - 'subscriptions.type.trial': 'Trial', - 'subscriptions.type.paid': 'Paid', - 'autopay.enabled': 'Enabled', - 'autopay.disabled': 'Disabled', - 'autopay.days_before_suffix': 'days before renewal', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'PC', @@ -791,13 +766,9 @@ 'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.', 'stats.days_left': 'Осталось дней', 'stats.servers': 'Серверы', - 'stats.devices': 'Устройства', 'info.expires': 'Действует до', 'info.traffic_used': 'Использовано трафика', 'info.traffic_limit': 'Лимит трафика', - 'info.subscription_type': 'Тип подписки', - 'info.autopay_status': 'Автоплатеж', - 'info.promo_group': 'Промогруппа', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -829,11 +800,6 @@ 'status.expired': 'Истекла', 'status.disabled': 'Отключена', 'status.unknown': 'Неизвестно', - 'subscriptions.type.trial': 'Триал', - 'subscriptions.type.paid': 'Платная', - 'autopay.enabled': 'Включен', - 'autopay.disabled': 'Выключен', - 'autopay.days_before_suffix': 'дн. до списания', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'ПК', @@ -1119,72 +1085,18 @@ document.getElementById('daysLeft').textContent = daysLeft; document.getElementById('expiresAt').textContent = formatDate(user.expires_at); - const connectedServers = Array.isArray(userData.connected_servers) - ? userData.connected_servers - : []; - const serversCount = connectedServers.length - || (Array.isArray(userData.connected_squads) - ? userData.connected_squads.length - : Array.isArray(userData.links) - ? userData.links.length - : 0); + const serversCount = Array.isArray(userData.connected_squads) + ? userData.connected_squads.length + : Array.isArray(userData.links) + ? userData.links.length + : 0; document.getElementById('serversCount').textContent = serversCount; - const connectedDevices = userData.connected_devices || {}; - const deviceLimitRaw = connectedDevices.limit ?? user.device_limit; - const deviceLimit = Number.isFinite(Number(deviceLimitRaw)) - ? Number(deviceLimitRaw) - : null; - const devicesCount = Number.isFinite(Number(connectedDevices.count)) - ? Number(connectedDevices.count) - : null; - let devicesText = '—'; - if (devicesCount !== null && deviceLimit !== null) { - devicesText = `${devicesCount}/${deviceLimit}`; - } else if (devicesCount !== null) { - devicesText = String(devicesCount); - } else if (deviceLimit !== null) { - devicesText = `—/${deviceLimit}`; - } - document.getElementById('devicesUsage').textContent = devicesText; - document.getElementById('trafficUsed').textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); - const subscriptionTypeElement = document.getElementById('subscriptionType'); - if (subscriptionTypeElement) { - const typeKey = user.subscription_type - ? `subscriptions.type.${user.subscription_type}` - : user.is_trial - ? 'subscriptions.type.trial' - : 'subscriptions.type.paid'; - const typeLabel = t(typeKey); - subscriptionTypeElement.textContent = - typeLabel === typeKey - ? (user.subscription_type || (user.is_trial ? t('subscriptions.type.trial') : t('subscriptions.type.paid'))) - : typeLabel; - } - - const autopayStatusElement = document.getElementById('autopayStatus'); - if (autopayStatusElement) { - const autopayEnabled = userData.autopay_enabled === true; - const baseLabel = t(autopayEnabled ? 'autopay.enabled' : 'autopay.disabled'); - const autopayDaysRaw = Number(userData.autopay_days_before); - const hasAutopayDays = autopayEnabled && Number.isFinite(autopayDaysRaw) && autopayDaysRaw > 0; - autopayStatusElement.textContent = hasAutopayDays - ? `${baseLabel} • ${autopayDaysRaw} ${t('autopay.days_before_suffix')}` - : baseLabel; - } - - const promoGroupElement = document.getElementById('promoGroup'); - if (promoGroupElement) { - promoGroupElement.textContent = (user.promo_group && user.promo_group.name) - ? user.promo_group.name - : '—'; - } - renderBalanceSection(); renderTransactionHistory(); renderServersList(); @@ -1520,13 +1432,7 @@ return; } - const fallbackServers = Array.isArray(userData?.connected_squads) - ? userData.connected_squads.map(uuid => ({ uuid, display_name: uuid })) - : []; - const servers = Array.isArray(userData?.connected_servers) && userData.connected_servers.length - ? userData.connected_servers - : fallbackServers; - + const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : []; if (!servers.length) { list.innerHTML = ''; emptyState.textContent = t('servers.empty'); @@ -1535,14 +1441,7 @@ } emptyState.classList.add('hidden'); - list.innerHTML = servers - .map(server => { - const name = typeof server === 'string' - ? server - : server?.display_name || server?.uuid || ''; - return `
            • ${escapeHtml(name)}
            • `; - }) - .join(''); + list.innerHTML = servers.map(server => `
            • ${escapeHtml(server)}
            • `).join(''); } function getCurrentSubscriptionUrl() { From 67650666fcfa3e9e0818ded2ca4a9b4017b79b2c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:45:51 +0300 Subject: [PATCH 12/40] Enhance miniapp subscription details --- app/webapi/routes/miniapp.py | 140 ++++++++++++++++++++- app/webapi/schemas/miniapp.py | 24 ++++ miniapp/index.html | 221 ++++++++++++++++++++++++++++++++-- 3 files changed, 374 insertions(+), 11 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 1cb0fb4d..244c6d2e 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,15 +1,20 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.user import get_user_by_telegram_id -from app.database.models import Subscription, Transaction +from app.database.models import Subscription, Transaction, User +from app.services.remnawave_service import ( + RemnaWaveConfigurationError, + RemnaWaveService, +) from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( @@ -19,6 +24,9 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( + MiniAppConnectedServer, + MiniAppDevice, + MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -71,6 +79,122 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) +def _parse_datetime_string(value: Optional[str]) -> Optional[str]: + if not value: + return None + + try: + cleaned = value.strip() + if cleaned.endswith("Z"): + cleaned = f"{cleaned[:-1]}+00:00" + # Normalize duplicated timezone suffixes like +00:00+00:00 + if "+00:00+00:00" in cleaned: + cleaned = cleaned.replace("+00:00+00:00", "+00:00") + + datetime.fromisoformat(cleaned) + return cleaned + except Exception: # pragma: no cover - defensive + return value + + +async def _resolve_connected_servers( + db: AsyncSession, + squad_uuids: List[str], +) -> List[MiniAppConnectedServer]: + if not squad_uuids: + return [] + + resolved: Dict[str, str] = {} + missing: List[str] = [] + + for squad_uuid in squad_uuids: + if squad_uuid in resolved: + continue + server = await get_server_squad_by_uuid(db, squad_uuid) + if server and server.display_name: + resolved[squad_uuid] = server.display_name + else: + missing.append(squad_uuid) + + if missing: + try: + service = RemnaWaveService() + if service.is_configured: + squads = await service.get_all_squads() + for squad in squads: + uuid = squad.get("uuid") + name = squad.get("name") + if uuid in missing and name: + resolved[uuid] = name + except RemnaWaveConfigurationError: + logger.debug("RemnaWave is not configured; skipping server name enrichment") + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to resolve server names from RemnaWave: %s", error) + + connected_servers: List[MiniAppConnectedServer] = [] + for squad_uuid in squad_uuids: + name = resolved.get(squad_uuid, squad_uuid) + connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) + + return connected_servers + + +async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + return 0, [] + + try: + service = RemnaWaveService() + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to initialise RemnaWave service: %s", error) + return 0, [] + + if not service.is_configured: + return 0, [] + + try: + async with service.get_api_client() as api: + response = await api.get_user_devices(remnawave_uuid) + except RemnaWaveConfigurationError: + logger.debug("RemnaWave configuration missing while loading devices") + return 0, [] + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load devices from RemnaWave: %s", error) + return 0, [] + + total_devices = int(response.get("total") or 0) + devices_payload = response.get("devices") or [] + + devices: List[MiniAppDevice] = [] + for device in devices_payload: + platform = device.get("platform") or device.get("platformType") + model = device.get("deviceModel") or device.get("model") or device.get("name") + app_version = device.get("appVersion") or device.get("version") + last_seen_raw = ( + device.get("updatedAt") + or device.get("lastSeen") + or device.get("lastActiveAt") + or device.get("createdAt") + ) + last_ip = device.get("ip") or device.get("ipAddress") + + devices.append( + MiniAppDevice( + platform=platform, + device_model=model, + app_version=app_version, + last_seen=_parse_datetime_string(last_seen_raw), + last_ip=last_ip, + ) + ) + + if total_devices == 0: + total_devices = len(devices) + + return total_devices, devices + + def _resolve_display_name(user_data: Dict[str, Any]) -> str: username = user_data.get("username") if username: @@ -186,6 +310,8 @@ async def get_subscription_details( happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads: List[str] = list(subscription.connected_squads or []) + connected_servers = await _resolve_connected_servers(db, connected_squads) + devices_count, devices = await _load_devices_info(user) links: List[str] = links_payload.get("links") or connected_squads ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} @@ -202,6 +328,8 @@ async def get_subscription_details( if isinstance(balance_currency, str): balance_currency = balance_currency.upper() + promo_group = getattr(user, "promo_group", None) + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -239,6 +367,9 @@ async def get_subscription_details( links=links, ss_conf_links=ss_conf_links, connected_squads=connected_squads, + connected_servers=connected_servers, + connected_devices_count=devices_count, + connected_devices=devices, happ=links_payload.get("happ"), happ_link=links_payload.get("happ_link"), happ_crypto_link=links_payload.get("happ_crypto_link"), @@ -247,5 +378,10 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], + promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name) + if promo_group + else None, + subscription_type="trial" if subscription.is_trial else "paid", + autopay_enabled=bool(subscription.autopay_enabled), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 7ce2b375..4c42e230 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -31,6 +31,24 @@ class MiniAppSubscriptionUser(BaseModel): has_active_subscription: bool = False +class MiniAppPromoGroup(BaseModel): + id: int + name: str + + +class MiniAppConnectedServer(BaseModel): + uuid: str + name: str + + +class MiniAppDevice(BaseModel): + platform: Optional[str] = None + device_model: Optional[str] = None + app_version: Optional[str] = None + last_seen: Optional[str] = None + last_ip: Optional[str] = None + + class MiniAppTransaction(BaseModel): id: int type: str @@ -54,6 +72,9 @@ class MiniAppSubscriptionResponse(BaseModel): links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) + connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list) + connected_devices_count: int = 0 + connected_devices: List[MiniAppDevice] = Field(default_factory=list) happ: Optional[Dict[str, Any]] = None happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None @@ -62,4 +83,7 @@ class MiniAppSubscriptionResponse(BaseModel): balance_rubles: float = 0.0 balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) + promo_group: Optional[MiniAppPromoGroup] = None + subscription_type: str + autopay_enabled: bool = False diff --git a/miniapp/index.html b/miniapp/index.html index 7af22803..7858021c 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -230,6 +230,39 @@ margin-bottom: 0; } + .device-list { + list-style: none; + padding: 0; + margin: 0; + } + + .device-item { + padding: 12px; + background: white; + border-radius: 10px; + font-size: 13px; + color: var(--text-primary); + box-shadow: inset 0 0 0 1px var(--border-color); + margin-bottom: 8px; + } + + .device-item:last-child { + margin-bottom: 0; + } + + .device-title { + font-weight: 600; + margin-bottom: 4px; + } + + .device-meta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 8px; + } + /* User Info */ .user-header { display: flex; @@ -302,7 +335,7 @@ /* Stats Grid */ .stats-grid { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 12px; } @@ -606,6 +639,10 @@
              -
              Servers
              +
              +
              -
              +
              Devices
              +
              @@ -620,6 +657,22 @@ Traffic Limit -
              +
              + Subscription Type + - +
              +
              + Promo Group + - +
              +
              + Device Limit + - +
              +
              + Auto-Pay + - +
              @@ -660,6 +713,12 @@
              +
              +
              Connected Devices
              +
                + +
                +
                @@ -716,15 +775,21 @@ 'error.default.message': 'Please contact support to activate your subscription.', 'stats.days_left': 'Days left', 'stats.servers': 'Servers', + 'stats.devices': 'Devices', 'info.expires': 'Expires', 'info.traffic_used': 'Traffic used', 'info.traffic_limit': 'Traffic limit', + 'info.subscription_type': 'Subscription type', + 'info.promo_group': 'Promo group', + 'info.device_limit': 'Device limit', + 'info.autopay': 'Auto-pay', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', + 'card.devices.title': 'Connected Devices', 'apps.title': 'Installation guide', 'apps.no_data': 'No installation guide available for this platform yet.', 'apps.featured': 'Recommended', @@ -740,6 +805,7 @@ 'history.type.refund': 'Refund', 'history.type.referral_reward': 'Referral reward', 'servers.empty': 'No servers connected yet', + 'devices.empty': 'No devices connected yet', 'language.ariaLabel': 'Select interface language', 'notifications.copy.success': 'Subscription link copied to clipboard.', 'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.', @@ -750,12 +816,17 @@ 'status.expired': 'Expired', 'status.disabled': 'Disabled', 'status.unknown': 'Unknown', + 'subscription.type.trial': 'Trial', + 'subscription.type.paid': 'Paid', + 'autopay.enabled': 'Enabled', + 'autopay.disabled': 'Disabled', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'PC', 'platform.tv': 'TV', 'units.gb': 'GB', - 'values.unlimited': 'Unlimited' + 'values.unlimited': 'Unlimited', + 'values.not_available': 'Not available' }, ru: { 'app.title': 'Подписка VPN', @@ -766,15 +837,21 @@ 'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.', 'stats.days_left': 'Осталось дней', 'stats.servers': 'Серверы', + 'stats.devices': 'Устройства', 'info.expires': 'Действует до', 'info.traffic_used': 'Использовано трафика', 'info.traffic_limit': 'Лимит трафика', + 'info.subscription_type': 'Тип подписки', + 'info.promo_group': 'Промогруппа', + 'info.device_limit': 'Лимит устройств', + 'info.autopay': 'Автоплатеж', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', + 'card.devices.title': 'Подключенные устройства', 'apps.title': 'Инструкция по установке', 'apps.no_data': 'Для этой платформы инструкция пока недоступна.', 'apps.featured': 'Рекомендуем', @@ -790,6 +867,7 @@ 'history.type.refund': 'Возврат', 'history.type.referral_reward': 'Реферальное вознаграждение', 'servers.empty': 'Подключённых серверов пока нет', + 'devices.empty': 'Подключённых устройств пока нет', 'language.ariaLabel': 'Выберите язык интерфейса', 'notifications.copy.success': 'Ссылка подписки скопирована.', 'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.', @@ -800,12 +878,17 @@ 'status.expired': 'Истекла', 'status.disabled': 'Отключена', 'status.unknown': 'Неизвестно', + 'subscription.type.trial': 'Триал', + 'subscription.type.paid': 'Платная', + 'autopay.enabled': 'Включен', + 'autopay.disabled': 'Выключен', 'platform.ios': 'iOS', 'platform.android': 'Android', 'platform.pc': 'ПК', - 'platform.tv': 'TV', + 'platform.tv': 'ТВ', 'units.gb': 'ГБ', - 'values.unlimited': 'Безлимит' + 'values.unlimited': 'Безлимит', + 'values.not_available': 'Недоступно' } }; @@ -1087,19 +1170,74 @@ const serversCount = Array.isArray(userData.connected_squads) ? userData.connected_squads.length - : Array.isArray(userData.links) - ? userData.links.length - : 0; + : Array.isArray(userData.connected_servers) + ? userData.connected_servers.length + : Array.isArray(userData.links) + ? userData.links.length + : 0; document.getElementById('serversCount').textContent = serversCount; + const devicesCountRaw = Number(userData?.connected_devices_count); + const devicesCount = Number.isFinite(devicesCountRaw) + ? devicesCountRaw + : Array.isArray(userData?.connected_devices) + ? userData.connected_devices.length + : 0; + const devicesCountElement = document.getElementById('devicesCount'); + if (devicesCountElement) { + devicesCountElement.textContent = devicesCount; + } + document.getElementById('trafficUsed').textContent = user.traffic_used_label || formatTraffic(user.traffic_used_gb); document.getElementById('trafficLimit').textContent = user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb); + const deviceLimitElement = document.getElementById('deviceLimit'); + if (deviceLimitElement) { + const limitValue = typeof user.device_limit === 'number' + ? user.device_limit + : Number.parseInt(user.device_limit ?? '', 10); + deviceLimitElement.textContent = Number.isFinite(limitValue) + ? String(limitValue) + : t('values.not_available'); + } + + const subscriptionTypeElement = document.getElementById('subscriptionType'); + if (subscriptionTypeElement) { + const fallbackSubscriptionType = (user?.subscription_status || '').toLowerCase() === 'trial' + ? 'trial' + : 'paid'; + const subscriptionTypeRaw = String( + userData?.subscription_type + || fallbackSubscriptionType + ).toLowerCase(); + const subscriptionTypeKey = `subscription.type.${subscriptionTypeRaw}`; + const subscriptionTypeLabel = t(subscriptionTypeKey); + subscriptionTypeElement.textContent = subscriptionTypeLabel === subscriptionTypeKey + ? subscriptionTypeRaw + : subscriptionTypeLabel; + } + + const promoGroupElement = document.getElementById('promoGroup'); + if (promoGroupElement) { + const promoGroupName = userData?.promo_group?.name || user?.promo_group?.name; + promoGroupElement.textContent = promoGroupName || t('values.not_available'); + } + + const autopayElement = document.getElementById('autopayStatus'); + if (autopayElement) { + const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled'; + const autopayLabel = t(autopayKey); + autopayElement.textContent = autopayLabel === autopayKey + ? (userData?.autopay_enabled ? 'On' : 'Off') + : autopayLabel; + } + renderBalanceSection(); renderTransactionHistory(); renderServersList(); + renderDevicesList(); updateConnectButtonLabel(); updateActionButtons(); } @@ -1432,7 +1570,16 @@ return; } - const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : []; + let servers = []; + if (Array.isArray(userData?.connected_servers) && userData.connected_servers.length) { + servers = userData.connected_servers.map(server => ({ + uuid: server?.uuid || '', + name: server?.name || server?.uuid || '', + })); + } else if (Array.isArray(userData?.connected_squads)) { + servers = userData.connected_squads.map(uuid => ({ uuid, name: uuid })); + } + if (!servers.length) { list.innerHTML = ''; emptyState.textContent = t('servers.empty'); @@ -1441,7 +1588,63 @@ } emptyState.classList.add('hidden'); - list.innerHTML = servers.map(server => `
              • ${escapeHtml(server)}
              • `).join(''); + list.innerHTML = servers + .map(server => `
              • ${escapeHtml(server.name || server.uuid || '')}
              • `) + .join(''); + } + + function renderDevicesList() { + const list = document.getElementById('devicesList'); + const emptyState = document.getElementById('devicesEmpty'); + if (!list || !emptyState) { + return; + } + + const devices = Array.isArray(userData?.connected_devices) + ? userData.connected_devices + : []; + + if (!devices.length) { + list.innerHTML = ''; + emptyState.textContent = t('devices.empty'); + emptyState.classList.remove('hidden'); + return; + } + + emptyState.classList.add('hidden'); + list.innerHTML = devices.map(device => { + const platform = device?.platform ? String(device.platform) : ''; + const model = device?.device_model ? String(device.device_model) : ''; + const titleParts = [platform, model].filter(Boolean); + const title = titleParts.length + ? titleParts.join(' — ') + : t('values.not_available'); + + const metaParts = []; + if (device?.app_version) { + metaParts.push(String(device.app_version)); + } + if (device?.last_seen) { + const formatted = formatDateTime(device.last_seen); + if (formatted) { + metaParts.push(formatted); + } + } + if (device?.last_ip) { + metaParts.push(String(device.last_ip)); + } + + const metaHtml = metaParts.length + ? `
                ${metaParts.map(part => `${escapeHtml(part)}`).join('')}
                ` + : ''; + + return ` +
              • +
                ${escapeHtml(title)}
                + ${metaHtml} +
              • + `; + }).join(''); } function getCurrentSubscriptionUrl() { From 0a789d3bd1cf3ded6b1be17818cfa370c72fc2c3 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:58:34 +0300 Subject: [PATCH 13/40] feat: allow configuring miniapp branding --- .env.example | 2 ++ README.md | 2 ++ app/config.py | 2 ++ app/services/system_settings_service.py | 2 ++ app/webapi/routes/miniapp.py | 12 +++++++++ app/webapi/schemas/miniapp.py | 2 ++ docs/miniapp-setup.md | 1 + miniapp/index.html | 33 ++++++++++++++++++++++++- 8 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 93e8a4ca..2bb28ef7 100644 --- a/.env.example +++ b/.env.example @@ -290,6 +290,8 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME=RemnaWave VPN +MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/README.md b/README.md index bbddbaa9..60e4b6ce 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,8 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME=RemnaWave VPN +MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/app/config.py b/app/config.py index a2cbaf80..74c0247f 100644 --- a/app/config.py +++ b/app/config.py @@ -212,6 +212,8 @@ class Settings(BaseSettings): PAL24_CARD_BUTTON_TEXT: Optional[str] = None CONNECT_BUTTON_MODE: str = "guide" + MINIAPP_SERVICE_NAME: str = "RemnaWave VPN" + MINIAPP_SERVICE_DESCRIPTION: str = "Secure & Fast Connection" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0df8e252..2367bb8c 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -161,6 +161,8 @@ class BotConfigurationService: "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", "INACTIVE_USER_DELETE_MONTHS": "MONITORING", + "MINIAPP_SERVICE_NAME": "INTERFACE_BRANDING", + "MINIAPP_SERVICE_DESCRIPTION": "INTERFACE_BRANDING", "LANGUAGE_SELECTION_ENABLED": "LOCALIZATION", } diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 244c6d2e..48fd006c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -79,6 +79,16 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) +def _miniapp_service_name() -> str: + value = (settings.MINIAPP_SERVICE_NAME or "").strip() + return value or "RemnaWave VPN" + + +def _miniapp_service_description() -> str: + value = (settings.MINIAPP_SERVICE_DESCRIPTION or "").strip() + return value or "Secure & Fast Connection" + + def _parse_datetime_string(value: Optional[str]) -> Optional[str]: if not value: return None @@ -359,6 +369,8 @@ async def get_subscription_details( ) return MiniAppSubscriptionResponse( + service_name=_miniapp_service_name(), + service_description=_miniapp_service_description(), subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, user=response_user, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 4c42e230..2dc7cc9c 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -64,6 +64,8 @@ class MiniAppTransaction(BaseModel): class MiniAppSubscriptionResponse(BaseModel): success: bool = True + service_name: Optional[str] = None + service_description: Optional[str] = None subscription_id: int remnawave_short_uuid: Optional[str] = None user: MiniAppSubscriptionUser diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md index 2ab7d0e0..3d2a9faf 100644 --- a/docs/miniapp-setup.md +++ b/docs/miniapp-setup.md @@ -30,6 +30,7 @@ - `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение. - `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`. 3. Если используете RemnaWave, убедитесь, что заданы `REMNAWAVE_API_URL` и `REMNAWAVE_API_KEY`, чтобы в мини-приложении отображались дополнительные ссылки подписки. +4. Настройте брендирование заголовка при необходимости: `MINIAPP_SERVICE_NAME` и `MINIAPP_SERVICE_DESCRIPTION` обновят название сервиса и описание в шапке мини-приложения. ## 3. Запуск административного API diff --git a/miniapp/index.html b/miniapp/index.html index 7858021c..e203c8f1 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -892,12 +892,40 @@ } }; + const DEFAULT_BRANDING = { + name: translations.en?.['app.name'] || 'RemnaWave VPN', + description: translations.en?.['app.subtitle'] || 'Secure & Fast Connection' + }; + let userData = null; let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; + let branding = { ...DEFAULT_BRANDING }; + + function applyBrandingConfig(payload) { + const nameRaw = typeof payload?.service_name === 'string' + ? payload.service_name.trim() + : ''; + const descriptionRaw = typeof payload?.service_description === 'string' + ? payload.service_description.trim() + : ''; + + branding = { + name: nameRaw || DEFAULT_BRANDING.name, + description: descriptionRaw || DEFAULT_BRANDING.description + }; + + SUPPORTED_LANGUAGES.forEach(lang => { + if (!translations[lang]) { + translations[lang] = {}; + } + translations[lang]['app.name'] = branding.name; + translations[lang]['app.subtitle'] = branding.description; + }); + } function resolveLanguage(lang) { if (!lang) { @@ -971,7 +999,8 @@ } function applyTranslations() { - document.title = t('app.title'); + const baseTitle = t('app.title'); + document.title = branding?.name ? `${branding.name} — ${baseTitle}` : baseTitle; document.documentElement.setAttribute('lang', preferredLanguage); document.querySelectorAll('[data-i18n]').forEach(element => { const key = element.getAttribute('data-i18n'); @@ -1101,6 +1130,8 @@ userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + applyBrandingConfig(userData); + const responseLanguage = resolveLanguage(userData?.user?.language); if (responseLanguage && !languageLockedByUser) { preferredLanguage = responseLanguage; From 7ace06834bf60d6a0c984d4a1e8b16b1297367c0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 03:59:49 +0300 Subject: [PATCH 14/40] Update .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2bb28ef7..159c81a2 100644 --- a/.env.example +++ b/.env.example @@ -290,7 +290,7 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME=RemnaWave VPN +MINIAPP_SERVICE_NAME=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink From d964a2d53c6d040a838f1b861a365901713153b4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:00:18 +0300 Subject: [PATCH 15/40] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60e4b6ce..82c7b520 100644 --- a/README.md +++ b/README.md @@ -535,7 +535,7 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME=RemnaWave VPN +MINIAPP_SERVICE_NAME=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink From e2f5191844def5a369860cfa0cf649262de19895 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:00:48 +0300 Subject: [PATCH 16/40] Update config.py --- app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 74c0247f..c807fdca 100644 --- a/app/config.py +++ b/app/config.py @@ -212,7 +212,7 @@ class Settings(BaseSettings): PAL24_CARD_BUTTON_TEXT: Optional[str] = None CONNECT_BUTTON_MODE: str = "guide" - MINIAPP_SERVICE_NAME: str = "RemnaWave VPN" + MINIAPP_SERVICE_NAME: str = "Bedolaga VPN" MINIAPP_SERVICE_DESCRIPTION: str = "Secure & Fast Connection" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False From cd9218ec09db6ff6df3e4e3c8d9d2ef2810da004 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:01:22 +0300 Subject: [PATCH 17/40] Revert "feat: allow configuring miniapp branding" --- .env.example | 2 -- README.md | 2 -- app/config.py | 2 -- app/services/system_settings_service.py | 2 -- app/webapi/routes/miniapp.py | 12 --------- app/webapi/schemas/miniapp.py | 2 -- docs/miniapp-setup.md | 1 - miniapp/index.html | 33 +------------------------ 8 files changed, 1 insertion(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 159c81a2..93e8a4ca 100644 --- a/.env.example +++ b/.env.example @@ -290,8 +290,6 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME=Bedolaga VPN -MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/README.md b/README.md index 82c7b520..bbddbaa9 100644 --- a/README.md +++ b/README.md @@ -535,8 +535,6 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME=Bedolaga VPN -MINIAPP_SERVICE_DESCRIPTION=Secure & Fast Connection # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/app/config.py b/app/config.py index c807fdca..a2cbaf80 100644 --- a/app/config.py +++ b/app/config.py @@ -212,8 +212,6 @@ class Settings(BaseSettings): PAL24_CARD_BUTTON_TEXT: Optional[str] = None CONNECT_BUTTON_MODE: str = "guide" - MINIAPP_SERVICE_NAME: str = "Bedolaga VPN" - MINIAPP_SERVICE_DESCRIPTION: str = "Secure & Fast Connection" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 2367bb8c..0df8e252 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -161,8 +161,6 @@ class BotConfigurationService: "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", "INACTIVE_USER_DELETE_MONTHS": "MONITORING", - "MINIAPP_SERVICE_NAME": "INTERFACE_BRANDING", - "MINIAPP_SERVICE_DESCRIPTION": "INTERFACE_BRANDING", "LANGUAGE_SELECTION_ENABLED": "LOCALIZATION", } diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 48fd006c..244c6d2e 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -79,16 +79,6 @@ def _status_label(status: str) -> str: return mapping.get(status, status.title()) -def _miniapp_service_name() -> str: - value = (settings.MINIAPP_SERVICE_NAME or "").strip() - return value or "RemnaWave VPN" - - -def _miniapp_service_description() -> str: - value = (settings.MINIAPP_SERVICE_DESCRIPTION or "").strip() - return value or "Secure & Fast Connection" - - def _parse_datetime_string(value: Optional[str]) -> Optional[str]: if not value: return None @@ -369,8 +359,6 @@ async def get_subscription_details( ) return MiniAppSubscriptionResponse( - service_name=_miniapp_service_name(), - service_description=_miniapp_service_description(), subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, user=response_user, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 2dc7cc9c..4c42e230 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -64,8 +64,6 @@ class MiniAppTransaction(BaseModel): class MiniAppSubscriptionResponse(BaseModel): success: bool = True - service_name: Optional[str] = None - service_description: Optional[str] = None subscription_id: int remnawave_short_uuid: Optional[str] = None user: MiniAppSubscriptionUser diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md index 3d2a9faf..2ab7d0e0 100644 --- a/docs/miniapp-setup.md +++ b/docs/miniapp-setup.md @@ -30,7 +30,6 @@ - `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение. - `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`. 3. Если используете RemnaWave, убедитесь, что заданы `REMNAWAVE_API_URL` и `REMNAWAVE_API_KEY`, чтобы в мини-приложении отображались дополнительные ссылки подписки. -4. Настройте брендирование заголовка при необходимости: `MINIAPP_SERVICE_NAME` и `MINIAPP_SERVICE_DESCRIPTION` обновят название сервиса и описание в шапке мини-приложения. ## 3. Запуск административного API diff --git a/miniapp/index.html b/miniapp/index.html index e203c8f1..7858021c 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -892,40 +892,12 @@ } }; - const DEFAULT_BRANDING = { - name: translations.en?.['app.name'] || 'RemnaWave VPN', - description: translations.en?.['app.subtitle'] || 'Secure & Fast Connection' - }; - let userData = null; let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; - let branding = { ...DEFAULT_BRANDING }; - - function applyBrandingConfig(payload) { - const nameRaw = typeof payload?.service_name === 'string' - ? payload.service_name.trim() - : ''; - const descriptionRaw = typeof payload?.service_description === 'string' - ? payload.service_description.trim() - : ''; - - branding = { - name: nameRaw || DEFAULT_BRANDING.name, - description: descriptionRaw || DEFAULT_BRANDING.description - }; - - SUPPORTED_LANGUAGES.forEach(lang => { - if (!translations[lang]) { - translations[lang] = {}; - } - translations[lang]['app.name'] = branding.name; - translations[lang]['app.subtitle'] = branding.description; - }); - } function resolveLanguage(lang) { if (!lang) { @@ -999,8 +971,7 @@ } function applyTranslations() { - const baseTitle = t('app.title'); - document.title = branding?.name ? `${branding.name} — ${baseTitle}` : baseTitle; + document.title = t('app.title'); document.documentElement.setAttribute('lang', preferredLanguage); document.querySelectorAll('[data-i18n]').forEach(element => { const key = element.getAttribute('data-i18n'); @@ -1130,8 +1101,6 @@ userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; - applyBrandingConfig(userData); - const responseLanguage = resolveLanguage(userData?.user?.language); if (responseLanguage && !languageLockedByUser) { preferredLanguage = responseLanguage; From cc96919672aae3e1ecba231c3f975e071a9b4c91 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:01:47 +0300 Subject: [PATCH 18/40] Add configurable miniapp branding --- .env.example | 4 ++ README.md | 4 ++ app/config.py | 32 ++++++++++++ app/services/system_settings_service.py | 2 + app/webapi/routes/miniapp.py | 1 + app/webapi/schemas/miniapp.py | 6 +++ miniapp/index.html | 69 +++++++++++++++++++++++++ 7 files changed, 118 insertions(+) diff --git a/.env.example b/.env.example index 93e8a4ca..699ea4e0 100644 --- a/.env.example +++ b/.env.example @@ -290,6 +290,10 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME_EN=RemnaWave VPN +MINIAPP_SERVICE_NAME_RU=RemnaWave VPN +MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection +MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/README.md b/README.md index bbddbaa9..eb088568 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,10 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME_EN=RemnaWave VPN +MINIAPP_SERVICE_NAME_RU=RemnaWave VPN +MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection +MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/app/config.py b/app/config.py index a2cbaf80..89f90522 100644 --- a/app/config.py +++ b/app/config.py @@ -213,6 +213,10 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + MINIAPP_SERVICE_NAME_EN: str = "RemnaWave VPN" + MINIAPP_SERVICE_NAME_RU: str = "RemnaWave VPN" + MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" + MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None @@ -518,6 +522,34 @@ class Settings(BaseSettings): def is_deep_links_enabled(self) -> bool: return self.ENABLE_DEEP_LINKS + + def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]: + def _clean(value: Optional[str]) -> Optional[str]: + if value is None: + return None + value_str = str(value).strip() + return value_str or None + + name_en = _clean(self.MINIAPP_SERVICE_NAME_EN) + name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU) + desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN) + desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU) + + default_name = name_en or name_ru or "RemnaWave VPN" + default_description = desc_en or desc_ru or "Secure & Fast Connection" + + return { + "service_name": { + "default": default_name, + "en": name_en, + "ru": name_ru, + }, + "service_description": { + "default": default_description, + "en": desc_en, + "ru": desc_ru, + }, + } def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0df8e252..1ce9432a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -89,6 +89,7 @@ class BotConfigurationService: "HAPP": "🅷 Happ настройки", "SKIP": "⚡ Быстрый старт", "ADDITIONAL": "📱 Приложения и DeepLinks", + "MINIAPP": "📱 Mini App", "DATABASE": "🗄️ Режим БД", "POSTGRES": "🐘 PostgreSQL", "SQLITE": "💾 SQLite", @@ -189,6 +190,7 @@ class BotConfigurationService: "CONNECT_BUTTON_HAPP": "HAPP", "HAPP_": "HAPP", "SKIP_": "SKIP", + "MINIAPP_": "MINIAPP", "MONITORING_": "MONITORING", "NOTIFICATION_": "NOTIFICATIONS", "SERVER_STATUS": "SERVER", diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 244c6d2e..64bc7bd4 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -383,5 +383,6 @@ async def get_subscription_details( else None, subscription_type="trial" if subscription.is_trial else "paid", autopay_enabled=bool(subscription.autopay_enabled), + branding=settings.get_miniapp_branding(), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 4c42e230..a0d5cb1a 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field +class MiniAppBranding(BaseModel): + service_name: Dict[str, Optional[str]] = Field(default_factory=dict) + service_description: Dict[str, Optional[str]] = Field(default_factory=dict) + + class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") @@ -86,4 +91,5 @@ class MiniAppSubscriptionResponse(BaseModel): promo_group: Optional[MiniAppPromoGroup] = None subscription_type: str autopay_enabled: bool = False + branding: Optional[MiniAppBranding] = None diff --git a/miniapp/index.html b/miniapp/index.html index 7858021c..b9c7da7b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -892,6 +892,72 @@ } }; + function applyBrandingOverrides(branding) { + if (!branding || typeof branding !== 'object') { + return; + } + + const { + service_name: rawServiceName = {}, + service_description: rawServiceDescription = {} + } = branding; + + function normalizeMap(map) { + const normalized = {}; + Object.entries(map || {}).forEach(([lang, value]) => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (!trimmed) { + return; + } + normalized[lang.toLowerCase()] = trimmed; + }); + return normalized; + } + + function applyKey(key, map) { + const normalized = normalizeMap(map); + if (!Object.keys(normalized).length) { + return; + } + + const defaultValue = normalized.default + || normalized.en + || normalized.ru + || null; + + const languages = new Set( + Object.keys(translations).map(lang => lang.toLowerCase()) + ); + + Object.keys(normalized).forEach(lang => { + if (lang !== 'default') { + languages.add(lang); + } + }); + + languages.forEach(lang => { + const value = Object.prototype.hasOwnProperty.call(normalized, lang) + ? normalized[lang] + : defaultValue; + if (!value) { + return; + } + const targetLang = lang.toLowerCase(); + if (!translations[targetLang]) { + translations[targetLang] = {}; + } + translations[targetLang][key] = value; + }); + } + + applyKey('app.name', rawServiceName); + applyKey('app.title', rawServiceName); + applyKey('app.subtitle', rawServiceDescription); + } + let userData = null; let appsConfig = {}; let currentPlatform = 'android'; @@ -1100,6 +1166,9 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + if (userData.branding) { + applyBrandingOverrides(userData.branding); + } const responseLanguage = resolveLanguage(userData?.user?.language); if (responseLanguage && !languageLockedByUser) { From 7d9d05b1e84f93ab90edcaf9620fd57465224ca5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:02:33 +0300 Subject: [PATCH 19/40] Update .env.example --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 699ea4e0..77f05953 100644 --- a/.env.example +++ b/.env.example @@ -290,8 +290,8 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME_EN=RemnaWave VPN -MINIAPP_SERVICE_NAME_RU=RemnaWave VPN +MINIAPP_SERVICE_NAME_EN=Bedolaga VPN +MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение From d82b24927b0b8d3151b8221ad8a9031ac3fa6851 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:02:53 +0300 Subject: [PATCH 20/40] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb088568..c1fdb9f1 100644 --- a/README.md +++ b/README.md @@ -535,8 +535,8 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SERVICE_NAME_EN=RemnaWave VPN -MINIAPP_SERVICE_NAME_RU=RemnaWave VPN +MINIAPP_SERVICE_NAME_EN=Bedolaga VPN +MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение From c3ca12a8a569805809f6c63b0824b49de3b24323 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:03:14 +0300 Subject: [PATCH 21/40] Update config.py --- app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config.py b/app/config.py index 89f90522..773f4f45 100644 --- a/app/config.py +++ b/app/config.py @@ -213,8 +213,8 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" - MINIAPP_SERVICE_NAME_EN: str = "RemnaWave VPN" - MINIAPP_SERVICE_NAME_RU: str = "RemnaWave VPN" + MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN" + MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN" MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False From 02e3faf1941908ddb4c3de999f551a9812484a99 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 04:25:14 +0300 Subject: [PATCH 22/40] Create index2.html --- miniapp/index2.html | 2397 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2397 insertions(+) create mode 100644 miniapp/index2.html diff --git a/miniapp/index2.html b/miniapp/index2.html new file mode 100644 index 00000000..1e5e4fa4 --- /dev/null +++ b/miniapp/index2.html @@ -0,0 +1,2397 @@ + + + + + + + VPN Subscription + + + + +
                + +
                +
                + +
                + +
                + + +
                +
                +
                +
                + +
                Secure & Fast Connection
                +
                + + +
                +
                +
                Loading your subscription...
                +
                + + + + + + +
                + + - - - -
                - -
                -
                - -
                - -
                - - -
                -
                -
                -
                - -
                Secure & Fast Connection
                -
                - - -
                -
                -
                Loading your subscription...
                -
                - - - - - - -
                - - From ed6ff293ea955fb14fb85e371a9f61acc6ce5ee5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 06:00:29 +0300 Subject: [PATCH 36/40] Revert "Add configurable purchase link to the mini app" --- .env.example | 1 - README.md | 1 - app/config.py | 8 ---- app/webapi/routes/miniapp.py | 13 ------ app/webapi/schemas/miniapp.py | 6 --- miniapp/index.html | 82 ----------------------------------- 6 files changed, 111 deletions(-) diff --git a/.env.example b/.env.example index a15da1fc..77f05953 100644 --- a/.env.example +++ b/.env.example @@ -290,7 +290,6 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SUBSCRIPTION_PURCHASE_URL= MINIAPP_SERVICE_NAME_EN=Bedolaga VPN MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection diff --git a/README.md b/README.md index c97c466d..c1fdb9f1 100644 --- a/README.md +++ b/README.md @@ -535,7 +535,6 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= -MINIAPP_SUBSCRIPTION_PURCHASE_URL= MINIAPP_SERVICE_NAME_EN=Bedolaga VPN MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection diff --git a/app/config.py b/app/config.py index 7ab0198b..773f4f45 100644 --- a/app/config.py +++ b/app/config.py @@ -213,7 +213,6 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" - MINIAPP_SUBSCRIPTION_PURCHASE_URL: str = "" MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN" MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN" MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" @@ -551,13 +550,6 @@ class Settings(BaseSettings): "ru": desc_ru, }, } - - def get_miniapp_purchase_url(self) -> Optional[str]: - value = getattr(self, "MINIAPP_SUBSCRIPTION_PURCHASE_URL", "") - if value is None: - return None - purchase_url = str(value).strip() - return purchase_url or None def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index eded3fa6..64bc7bd4 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -24,8 +24,6 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( - MiniAppBranding, - MiniAppConfigResponse, MiniAppConnectedServer, MiniAppDevice, MiniAppPromoGroup, @@ -260,16 +258,6 @@ async def _load_subscription_links( return payload -@router.get("/config", response_model=MiniAppConfigResponse) -async def get_miniapp_config() -> MiniAppConfigResponse: - branding_data = settings.get_miniapp_branding() - branding = MiniAppBranding(**branding_data) if branding_data else None - return MiniAppConfigResponse( - branding=branding, - subscription_purchase_url=settings.get_miniapp_purchase_url(), - ) - - @router.post("/subscription", response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, @@ -395,7 +383,6 @@ async def get_subscription_details( else None, subscription_type="trial" if subscription.is_trial else "paid", autopay_enabled=bool(subscription.autopay_enabled), - subscription_purchase_url=settings.get_miniapp_purchase_url(), branding=settings.get_miniapp_branding(), ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 8effe4bb..a0d5cb1a 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -11,11 +11,6 @@ class MiniAppBranding(BaseModel): service_description: Dict[str, Optional[str]] = Field(default_factory=dict) -class MiniAppConfigResponse(BaseModel): - branding: Optional[MiniAppBranding] = None - subscription_purchase_url: Optional[str] = None - - class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") @@ -79,7 +74,6 @@ class MiniAppSubscriptionResponse(BaseModel): user: MiniAppSubscriptionUser subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None - subscription_purchase_url: Optional[str] = None links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index 9e1fed9b..27253b84 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -314,16 +314,6 @@ line-height: 1.6; } - .error-actions { - margin-top: 24px; - display: flex; - justify-content: center; - } - - .error-actions .btn { - min-width: 200px; - } - /* Cards */ .card { background: var(--bg-secondary); @@ -1196,9 +1186,6 @@
                ⚠️
                Subscription Not Found
                Please contact support to activate your subscription
                -
                - -
                @@ -1535,7 +1522,6 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', - 'button.purchase': 'Buy subscription', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -1598,7 +1584,6 @@ 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', - 'button.purchase': 'Купить подписку', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -1711,10 +1696,6 @@ } let userData = null; - let miniAppConfig = { - subscriptionPurchaseUrl: null, - branding: null, - }; let appsConfig = {}; let currentPlatform = 'android'; let preferredLanguage = 'en'; @@ -1790,18 +1771,6 @@ const message = currentErrorState?.message || t('error.default.message'); titleElement.textContent = title; textElement.textContent = message; - updatePurchaseButton(); - } - - function updatePurchaseButton() { - const purchaseBtn = document.getElementById('purchaseBtn'); - if (!purchaseBtn) { - return; - } - const link = getPurchaseLink(); - const hasLink = Boolean(link); - purchaseBtn.classList.toggle('hidden', !hasLink); - purchaseBtn.disabled = !hasLink; } function applyTranslations() { @@ -1898,7 +1867,6 @@ } } - await loadMiniAppConfig(); await loadAppsConfig(); const initData = tg.initData || ''; @@ -1935,14 +1903,9 @@ userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; - userData.subscriptionPurchaseUrl = - (userData.subscription_purchase_url - || miniAppConfig.subscriptionPurchaseUrl - || '').trim() || null; if (userData.branding) { applyBrandingOverrides(userData.branding); } - updatePurchaseButton(); const responseLanguage = resolveLanguage(userData?.user?.language); if (responseLanguage && !languageLockedByUser) { @@ -1968,36 +1931,6 @@ } } - async function loadMiniAppConfig() { - try { - const response = await fetch('/miniapp/config', { cache: 'no-cache' }); - if (!response.ok) { - throw new Error('Failed to load mini app config'); - } - - const data = await response.json(); - miniAppConfig = { - subscriptionPurchaseUrl: - (data?.subscription_purchase_url - || data?.subscriptionPurchaseUrl - || '').trim() || null, - branding: data?.branding || null, - }; - - if (miniAppConfig.branding) { - applyBrandingOverrides(miniAppConfig.branding); - } - } catch (error) { - console.warn('Unable to load mini app config:', error); - miniAppConfig = { - subscriptionPurchaseUrl: null, - branding: null, - }; - } finally { - updatePurchaseButton(); - } - } - async function loadAppsConfig() { try { const response = await fetch('/app-config.json', { cache: 'no-cache' }); @@ -2531,16 +2464,6 @@ ); } - function getPurchaseLink() { - if (userData?.subscriptionPurchaseUrl) { - return userData.subscriptionPurchaseUrl; - } - if (miniAppConfig?.subscriptionPurchaseUrl) { - return miniAppConfig.subscriptionPurchaseUrl; - } - return null; - } - function getConnectLink() { if (!userData) { return null; @@ -2666,11 +2589,6 @@ } }); - document.getElementById('purchaseBtn')?.addEventListener('click', () => { - const link = getPurchaseLink(); - openExternalLink(link); - }); - init(); From 7c96b0f71c380db2d8ceb15b763dbccd014f4e06 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 06:01:12 +0300 Subject: [PATCH 37/40] Add purchase link support to mini app error state --- app/config.py | 1 + app/webapi/routes/miniapp.py | 12 +++- app/webapi/schemas/miniapp.py | 1 + miniapp/index.html | 116 +++++++++++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 773f4f45..991efca3 100644 --- a/app/config.py +++ b/app/config.py @@ -213,6 +213,7 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + MINIAPP_PURCHASE_URL: str = "" MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN" MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN" MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 64bc7bd4..1a446032 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select @@ -287,10 +287,17 @@ async def get_subscription_details( ) from None user = await get_user_by_telegram_id(db, telegram_id) + purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() if not user or not user.subscription: + detail: Union[str, Dict[str, str]] = "Subscription not found" + if purchase_url: + detail = { + "message": "Subscription not found", + "purchase_url": purchase_url, + } raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Subscription not found", + detail=detail, ) subscription = user.subscription @@ -364,6 +371,7 @@ async def get_subscription_details( user=response_user, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, + subscription_purchase_url=purchase_url or None, links=links, ss_conf_links=ss_conf_links, connected_squads=connected_squads, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index a0d5cb1a..3461e170 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -74,6 +74,7 @@ class MiniAppSubscriptionResponse(BaseModel): user: MiniAppSubscriptionUser subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None + subscription_purchase_url: Optional[str] = None links: List[str] = Field(default_factory=list) ss_conf_links: Dict[str, str] = Field(default_factory=dict) connected_squads: List[str] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index 27253b84..a87ee062 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -314,6 +314,17 @@ line-height: 1.6; } + .error-actions { + margin-top: 24px; + display: flex; + justify-content: center; + } + + .error-actions .btn { + width: auto; + min-width: 220px; + } + /* Cards */ .card { background: var(--bg-secondary); @@ -1186,6 +1197,14 @@
                ⚠️
                Subscription Not Found
                Please contact support to activate your subscription
                +
                + +
                @@ -1522,6 +1541,7 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', + 'button.buy_subscription': 'Buy subscription', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -1584,6 +1604,7 @@ 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', + 'button.buy_subscription': 'Купить подписку', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -1698,6 +1719,8 @@ let userData = null; let appsConfig = {}; let currentPlatform = 'android'; + let configPurchaseUrl = null; + let subscriptionPurchaseUrl = null; let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; @@ -1761,6 +1784,31 @@ return div.innerHTML; } + function normalizeUrl(value) { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + } + + function getEffectivePurchaseUrl() { + const candidates = [ + currentErrorState?.purchaseUrl, + subscriptionPurchaseUrl, + configPurchaseUrl, + ]; + + for (const candidate of candidates) { + const normalized = normalizeUrl(candidate); + if (normalized) { + return normalized; + } + } + + return null; + } + function updateErrorTexts() { const titleElement = document.getElementById('errorTitle'); const textElement = document.getElementById('errorText'); @@ -1771,6 +1819,13 @@ const message = currentErrorState?.message || t('error.default.message'); titleElement.textContent = title; textElement.textContent = message; + + const purchaseButton = document.getElementById('purchaseBtn'); + if (purchaseButton) { + const link = getEffectivePurchaseUrl(); + purchaseButton.classList.toggle('hidden', !link); + purchaseButton.disabled = !link; + } } function applyTranslations() { @@ -1887,22 +1942,55 @@ ? 'Authorization failed. Please open the mini app from Telegram.' : 'Subscription not found'; let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; + let purchaseUrl = null; try { const errorPayload = await response.json(); if (errorPayload?.detail) { - detail = errorPayload.detail; + if (typeof errorPayload.detail === 'string') { + detail = errorPayload.detail; + } else if (typeof errorPayload.detail === 'object') { + if (typeof errorPayload.detail.message === 'string') { + detail = errorPayload.detail.message; + } + purchaseUrl = errorPayload.detail.purchase_url + || errorPayload.detail.purchaseUrl + || purchaseUrl; + } + } else if (typeof errorPayload?.message === 'string') { + detail = errorPayload.message; } + + if (typeof errorPayload?.title === 'string') { + title = errorPayload.title; + } + + purchaseUrl = purchaseUrl + || errorPayload?.purchase_url + || errorPayload?.purchaseUrl + || null; } catch (parseError) { // ignore } - throw createError(title, detail, response.status); + const errorObject = createError(title, detail, response.status); + const normalizedPurchaseUrl = normalizeUrl(purchaseUrl); + if (normalizedPurchaseUrl) { + errorObject.purchaseUrl = normalizedPurchaseUrl; + } + throw errorObject; } userData = await response.json(); userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + subscriptionPurchaseUrl = normalizeUrl( + userData.subscription_purchase_url + || userData.subscriptionPurchaseUrl + ); + if (subscriptionPurchaseUrl) { + userData.subscriptionPurchaseUrl = subscriptionPurchaseUrl; + } if (userData.branding) { applyBrandingOverrides(userData.branding); } @@ -1940,6 +2028,21 @@ const data = await response.json(); appsConfig = data?.platforms || {}; + + const configData = data?.config || {}; + const configUrl = normalizeUrl( + configData.subscriptionPurchaseUrl + || configData.subscription_purchase_url + || configData.purchaseUrl + || configData.purchase_url + || configData.miniappPurchaseUrl + || configData.miniapp_purchase_url + || data?.subscriptionPurchaseUrl + || data?.subscription_purchase_url + ); + if (configUrl) { + configPurchaseUrl = configUrl; + } } catch (error) { console.warn('Unable to load apps configuration:', error); appsConfig = {}; @@ -2554,6 +2657,7 @@ currentErrorState = { title: error?.title, message: error?.message, + purchaseUrl: normalizeUrl(error?.purchaseUrl) || null, }; updateErrorTexts(); document.getElementById('errorState').classList.remove('hidden'); @@ -2589,6 +2693,14 @@ } }); + document.getElementById('purchaseBtn')?.addEventListener('click', () => { + const link = getEffectivePurchaseUrl(); + if (!link) { + return; + } + openExternalLink(link); + }); + init(); From cbcf2d55da41f894caa8e4652198b58278f8de21 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 06:08:18 +0300 Subject: [PATCH 38/40] Adjust miniapp purchase button behavior --- miniapp/index.html | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index a87ee062..377cc8d3 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -1203,7 +1203,7 @@ class="btn btn-primary hidden" type="button" data-i18n="button.buy_subscription" - >Buy subscription + >Buy Subscription
                @@ -1541,7 +1541,7 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', - 'button.buy_subscription': 'Buy subscription', + 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -1604,7 +1604,7 @@ 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', - 'button.buy_subscription': 'Купить подписку', + 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -2621,6 +2621,23 @@ window.location.href = link; } + function openInMiniApp(link) { + if (!link) { + return; + } + + if (typeof tg.openLink === 'function') { + try { + tg.openLink(link, { try_instant_view: false }); + return; + } catch (error) { + console.warn('tg.openLink failed:', error); + } + } + + window.location.href = link; + } + function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); @@ -2698,7 +2715,7 @@ if (!link) { return; } - openExternalLink(link); + openInMiniApp(link); }); init(); From 9ffbba72599b1597e1eafe12cd223b8533e18cd6 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 06:10:02 +0300 Subject: [PATCH 39/40] Revert "Adjust miniapp purchase button behavior" --- miniapp/index.html | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 377cc8d3..a87ee062 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -1203,7 +1203,7 @@ class="btn btn-primary hidden" type="button" data-i18n="button.buy_subscription" - >Buy Subscription + >Buy subscription
                @@ -1541,7 +1541,7 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', - 'button.buy_subscription': 'Buy Subscription', + 'button.buy_subscription': 'Buy subscription', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -1604,7 +1604,7 @@ 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', - 'button.buy_subscription': 'Buy Subscription', + 'button.buy_subscription': 'Купить подписку', 'card.balance.title': 'Баланс', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -2621,23 +2621,6 @@ window.location.href = link; } - function openInMiniApp(link) { - if (!link) { - return; - } - - if (typeof tg.openLink === 'function') { - try { - tg.openLink(link, { try_instant_view: false }); - return; - } catch (error) { - console.warn('tg.openLink failed:', error); - } - } - - window.location.href = link; - } - function updateActionButtons() { const connectBtn = document.getElementById('connectBtn'); const copyBtn = document.getElementById('copyBtn'); @@ -2715,7 +2698,7 @@ if (!link) { return; } - openInMiniApp(link); + openExternalLink(link); }); init(); From c63c483b9b3ba25d2e884e83c7bec219ce24ff22 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 06:10:37 +0300 Subject: [PATCH 40/40] Adjust purchase button localization and navigation --- miniapp/index.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index a87ee062..cfea1fef 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -1541,7 +1541,7 @@ 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', - 'button.buy_subscription': 'Buy subscription', + 'button.buy_subscription': 'Buy Subscription', 'card.balance.title': 'Balance', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -2598,11 +2598,18 @@ return subscriptionUrl; } - function openExternalLink(link) { + function openExternalLink(link, options = {}) { if (!link) { return; } + const { openInMiniApp = false } = options; + + if (openInMiniApp) { + window.location.href = link; + return; + } + if (typeof tg.openLink === 'function') { try { tg.openLink(link, { try_instant_view: false }); @@ -2698,7 +2705,7 @@ if (!link) { return; } - openExternalLink(link); + openExternalLink(link, { openInMiniApp: true }); }); init();