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();