Merge pull request #1174 from Fr1ngg/cvpz16-bedolaga/update-miniapp/index.html-logic

Improve miniapp onboarding for unsubscribed users
This commit is contained in:
Egor
2025-10-11 01:57:16 +03:00
committed by GitHub
2 changed files with 492 additions and 14 deletions

View File

@@ -2055,6 +2055,280 @@ async def _build_referral_info(
)
async def _build_subscription_missing_response(
db: AsyncSession,
user: User,
purchase_url: str,
) -> MiniAppSubscriptionResponse:
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()
promo_group = getattr(user, "promo_group", None)
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
auto_assign_groups = await get_auto_assign_promo_groups(db)
auto_promo_levels: List[MiniAppAutoPromoGroupLevel] = []
for group in auto_assign_groups:
threshold = group.auto_assign_total_spent_kopeks or 0
if threshold <= 0:
continue
auto_promo_levels.append(
MiniAppAutoPromoGroupLevel(
id=group.id,
name=group.name,
threshold_kopeks=threshold,
threshold_rubles=round(threshold / 100, 2),
threshold_label=settings.format_price(threshold),
is_reached=total_spent_kopeks >= threshold,
is_current=bool(promo_group and promo_group.id == group.id),
**_extract_promo_discounts(group),
)
)
active_discount_percent = 0
try:
active_discount_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
active_discount_percent = 0
active_discount_expires_at = getattr(user, "promo_offer_discount_expires_at", None)
now = datetime.utcnow()
if active_discount_expires_at and active_discount_expires_at <= now:
active_discount_expires_at = None
active_discount_percent = 0
available_promo_offers = await list_active_discount_offers_for_user(db, user.id)
promo_offer_source = getattr(user, "promo_offer_discount_source", None)
active_offer_contexts: List[ActiveOfferContext] = []
if promo_offer_source or active_discount_percent > 0:
active_discount_offer = await get_latest_claimed_offer_for_user(
db,
user.id,
promo_offer_source,
)
if active_discount_offer and active_discount_percent > 0:
active_offer_contexts.append(
(
active_discount_offer,
active_discount_percent,
active_discount_expires_at,
)
)
promo_offers = await _build_promo_offer_models(
db,
available_promo_offers,
active_offer_contexts,
user=user,
)
content_language_preference = user.language or settings.DEFAULT_LANGUAGE or "ru"
requested_faq_language = FaqService.normalize_language(content_language_preference)
faq_pages = await FaqService.get_pages(
db,
requested_faq_language,
include_inactive=False,
fallback=True,
)
faq_payload: Optional[MiniAppFaq] = None
if faq_pages:
faq_setting = await FaqService.get_setting(
db,
requested_faq_language,
fallback=True,
)
is_enabled = bool(faq_setting.is_enabled) if faq_setting else True
if is_enabled:
ordered_pages = sorted(
faq_pages,
key=lambda page: (
(page.display_order or 0),
page.id,
),
)
faq_items: List[MiniAppFaqItem] = []
for page in ordered_pages:
raw_content = (page.content or "").strip()
if not raw_content:
continue
if not re.sub(r"<[^>]+>", "", raw_content).strip():
continue
faq_items.append(
MiniAppFaqItem(
id=page.id,
title=page.title or None,
content=page.content or "",
display_order=getattr(page, "display_order", None),
)
)
if faq_items:
resolved_language = (
faq_setting.language
if faq_setting and faq_setting.language
else ordered_pages[0].language
)
faq_payload = MiniAppFaq(
requested_language=requested_faq_language,
language=resolved_language or requested_faq_language,
is_enabled=is_enabled,
total=len(faq_items),
items=faq_items,
)
legal_documents_payload: Optional[MiniAppLegalDocuments] = None
requested_offer_language = PublicOfferService.normalize_language(content_language_preference)
public_offer = await PublicOfferService.get_active_offer(
db,
requested_offer_language,
)
if public_offer and (public_offer.content or "").strip():
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
legal_documents_payload.public_offer = MiniAppRichTextDocument(
requested_language=requested_offer_language,
language=public_offer.language,
title=None,
is_enabled=bool(public_offer.is_enabled),
content=public_offer.content or "",
created_at=public_offer.created_at,
updated_at=public_offer.updated_at,
)
requested_policy_language = PrivacyPolicyService.normalize_language(
content_language_preference
)
privacy_policy = await PrivacyPolicyService.get_active_policy(
db,
requested_policy_language,
)
if privacy_policy and (privacy_policy.content or "").strip():
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
legal_documents_payload.privacy_policy = MiniAppRichTextDocument(
requested_language=requested_policy_language,
language=privacy_policy.language,
title=None,
is_enabled=bool(privacy_policy.is_enabled),
content=privacy_policy.content or "",
created_at=privacy_policy.created_at,
updated_at=privacy_policy.updated_at,
)
requested_rules_language = (content_language_preference or "ru").split("-")[0].lower()
default_rules_language = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
service_rules = await get_rules_by_language(db, requested_rules_language)
if not service_rules and requested_rules_language != default_rules_language:
service_rules = await get_rules_by_language(db, default_rules_language)
if service_rules and (service_rules.content or "").strip():
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
legal_documents_payload.service_rules = MiniAppRichTextDocument(
requested_language=requested_rules_language,
language=service_rules.language,
title=getattr(service_rules, "title", None),
is_enabled=bool(getattr(service_rules, "is_active", True)),
content=service_rules.content or "",
created_at=getattr(service_rules, "created_at", None),
updated_at=getattr(service_rules, "updated_at", None),
)
devices_count, devices = await _load_devices_info(user)
lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0))
default_device_limit = settings.DEFAULT_DEVICE_LIMIT if settings.DEFAULT_DEVICE_LIMIT > 0 else None
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="inactive",
subscription_actual_status="inactive",
status_label=_status_label("inactive"),
expires_at=None,
device_limit=default_device_limit,
traffic_used_gb=0.0,
traffic_used_label=_format_gb_label(0.0),
traffic_limit_gb=None,
traffic_limit_label=_format_limit_label(None),
lifetime_used_traffic_gb=lifetime_used,
has_active_subscription=False,
promo_offer_discount_percent=active_discount_percent,
promo_offer_discount_expires_at=active_discount_expires_at,
promo_offer_discount_source=promo_offer_source,
)
referral_info = await _build_referral_info(db, user)
return MiniAppSubscriptionResponse(
subscription_id=0,
remnawave_short_uuid=None,
user=response_user,
subscription_url=None,
subscription_crypto_link=None,
subscription_purchase_url=purchase_url or None,
links=[],
ss_conf_links={},
connected_squads=[],
connected_servers=[],
connected_devices_count=devices_count,
connected_devices=devices,
happ=None,
happ_link=None,
happ_crypto_link=None,
happ_cryptolink_redirect_link=None,
balance_kopeks=user.balance_kopeks,
balance_rubles=round(getattr(user, "balance_rubles", user.balance_kopeks / 100), 2),
balance_currency=balance_currency,
transactions=[_serialize_transaction(tx) for tx in transactions],
promo_offers=promo_offers,
promo_group=(
MiniAppPromoGroup(
id=promo_group.id,
name=promo_group.name,
**_extract_promo_discounts(promo_group),
)
if promo_group
else None
),
auto_assign_promo_groups=auto_promo_levels,
total_spent_kopeks=total_spent_kopeks,
total_spent_rubles=round(total_spent_kopeks / 100, 2),
total_spent_label=settings.format_price(total_spent_kopeks),
subscription_type="none",
autopay_enabled=False,
branding=settings.get_miniapp_branding(),
faq=faq_payload,
legal_documents=legal_documents_payload,
referral=referral_info,
)
@router.post("/subscription", response_model=MiniAppSubscriptionResponse)
async def get_subscription_details(
payload: MiniAppSubscriptionRequest,
@@ -2085,18 +2359,26 @@ async def get_subscription_details(
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"
bot_username = settings.get_bot_username()
bot_url = f"https://t.me/{bot_username}" if bot_username else None
if not user:
detail: Dict[str, Optional[str]] = {
"code": "user_not_registered",
"message": "User is not registered in the bot",
}
if purchase_url:
detail = {
"message": "Subscription not found",
"purchase_url": purchase_url,
}
detail["purchase_url"] = purchase_url
if bot_url:
detail["bot_url"] = bot_url
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)
if not user.subscription:
return await _build_subscription_missing_response(db, user, purchase_url)
subscription = user.subscription
traffic_used = _format_gb(subscription.traffic_used_gb)
traffic_limit = subscription.traffic_limit_gb or 0

View File

@@ -331,6 +331,46 @@
min-width: 220px;
}
.state-card {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 32px 24px;
text-align: center;
box-shadow: var(--shadow-sm);
margin: 20px 0;
}
.state-icon {
font-size: 56px;
margin-bottom: 16px;
}
.state-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-primary);
}
.state-text {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 24px;
}
.state-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.state-actions .btn {
width: auto;
min-width: 200px;
}
/* Cards */
.card {
background: var(--bg-secondary);
@@ -1882,6 +1922,11 @@
color: #41464b;
}
.status-inactive {
background: linear-gradient(135deg, #e7e9ff, #f0f2ff);
color: #3f3d56;
}
/* Stats Grid */
.stats-grid {
display: grid;
@@ -4223,11 +4268,31 @@
</div>
</div>
<!-- Registration State -->
<div id="registerState" class="state-card hidden">
<div class="state-icon">🤖</div>
<div class="state-title" data-i18n="register_required.title">Registration required</div>
<div class="state-text" data-i18n="register_required.description">Open the bot to create an account and start using the VPN.</div>
<div class="state-actions">
<button class="btn btn-primary" id="registerBtn" type="button" data-i18n="register_required.action">Open bot</button>
</div>
</div>
<!-- Main Content -->
<div id="mainContent" class="hidden">
<!-- Promo Offers -->
<div id="promoOffersContainer" class="promo-offers hidden"></div>
<!-- Empty Subscription State -->
<div class="state-card hidden" id="subscriptionEmptyCard">
<div class="state-icon">🛒</div>
<div class="state-title" data-i18n="empty_subscription.title">Subscription inactive</div>
<div class="state-text" data-i18n="empty_subscription.description">You don't have an active subscription yet. Purchase a plan or activate a trial in the bot.</div>
<div class="state-actions">
<button class="btn btn-primary" id="subscriptionEmptyAction" type="button" data-i18n="empty_subscription.action">Open bot</button>
</div>
</div>
<!-- User Card -->
<div class="card user-card animate-in">
<div class="user-header">
@@ -4972,6 +5037,12 @@
'app.loading': 'Loading your subscription...',
'error.default.title': 'Subscription Not Found',
'error.default.message': 'Please contact support to activate your subscription.',
'register_required.title': 'Registration required',
'register_required.description': 'Open the bot to register and manage your subscription.',
'register_required.action': 'Open bot',
'empty_subscription.title': 'Subscription inactive',
'empty_subscription.description': 'You do not have an active subscription yet. Purchase a plan or activate a trial in the bot.',
'empty_subscription.action': 'Open bot',
'stats.days_left': 'Days left',
'stats.servers': 'Servers',
'stats.devices': 'Devices',
@@ -4985,6 +5056,7 @@
'button.connect.default': 'Connect to VPN',
'button.connect.happ': 'Connect',
'button.copy': 'Copy subscription link',
'button.open_bot': 'Open bot',
'button.topup_balance': 'Top up balance',
'topup.title': 'Top up balance',
'topup.subtitle': 'Choose a payment method',
@@ -5280,9 +5352,11 @@
'status.trial': 'Trial',
'status.expired': 'Expired',
'status.disabled': 'Disabled',
'status.inactive': 'Inactive',
'status.unknown': 'Unknown',
'subscription.type.trial': 'Trial',
'subscription.type.paid': 'Paid',
'subscription.type.none': 'No subscription',
'autopay.enabled': 'Enabled',
'autopay.disabled': 'Disabled',
'platform.ios': 'iOS',
@@ -5327,6 +5401,12 @@
'app.loading': 'Загружаем вашу подписку...',
'error.default.title': 'Подписка не найдена',
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
'register_required.title': 'Требуется регистрация',
'register_required.description': 'Откройте бота, чтобы зарегистрироваться и управлять подпиской.',
'register_required.action': 'Открыть бота',
'empty_subscription.title': 'Подписка не активна',
'empty_subscription.description': 'У вас нет активной подписки. Купите тариф или активируйте триал в боте.',
'empty_subscription.action': 'Открыть бота',
'stats.days_left': 'Осталось дней',
'stats.servers': 'Серверы',
'stats.devices': 'Устройства',
@@ -5340,6 +5420,7 @@
'button.connect.default': 'Подключиться к VPN',
'button.connect.happ': 'Подключиться',
'button.copy': 'Скопировать ссылку подписки',
'button.open_bot': 'Открыть бота',
'button.topup_balance': 'Пополнить баланс',
'topup.title': 'Пополнение баланса',
'topup.subtitle': 'Выберите способ оплаты',
@@ -5635,9 +5716,11 @@
'status.trial': 'Пробная',
'status.expired': 'Истекла',
'status.disabled': 'Отключена',
'status.inactive': 'Неактивна',
'status.unknown': 'Неизвестно',
'subscription.type.trial': 'Триал',
'subscription.type.paid': 'Платная',
'subscription.type.none': 'Нет подписки',
'autopay.enabled': 'Включен',
'autopay.disabled': 'Выключен',
'platform.ios': 'iOS',
@@ -6459,6 +6542,7 @@
function getEffectivePurchaseUrl() {
const candidates = [
currentErrorState?.botUrl,
currentErrorState?.purchaseUrl,
subscriptionPurchaseUrl,
configPurchaseUrl,
@@ -6493,6 +6577,31 @@
}
}
function updateRegisterState() {
const registerBtn = document.getElementById('registerBtn');
if (!registerBtn) {
return;
}
const sources = [
currentErrorState?.botUrl,
currentErrorState?.purchaseUrl,
configPurchaseUrl,
];
let link = null;
for (const source of sources) {
const normalized = normalizeUrl(source);
if (normalized) {
link = normalized;
break;
}
}
registerBtn.disabled = !link;
registerBtn.dataset.link = link || '';
}
function applyTranslations() {
document.title = t('app.title');
document.documentElement.setAttribute('lang', preferredLanguage);
@@ -6516,6 +6625,7 @@
languageSelect.setAttribute('aria-label', t('language.ariaLabel'));
}
updateErrorTexts();
updateRegisterState();
}
function updateConnectButtonLabel() {
@@ -6571,7 +6681,7 @@
setLanguage(event.target.value, { persist: true });
});
function createError(title, message, status) {
function createError(title, message, status, extra = null) {
const error = new Error(message || title);
if (title) {
error.title = title;
@@ -6579,6 +6689,9 @@
if (status) {
error.status = status;
}
if (extra && typeof extra === 'object') {
Object.assign(error, extra);
}
return error;
}
@@ -6622,6 +6735,8 @@
: 'Subscription not found';
let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found';
let purchaseUrl = null;
let detailCode = null;
let botUrl = null;
try {
const errorPayload = await response.json();
@@ -6632,9 +6747,18 @@
if (typeof errorPayload.detail.message === 'string') {
detail = errorPayload.detail.message;
}
if (typeof errorPayload.detail.code === 'string') {
detailCode = errorPayload.detail.code;
}
if (typeof errorPayload.detail.title === 'string') {
title = errorPayload.detail.title;
}
purchaseUrl = errorPayload.detail.purchase_url
|| errorPayload.detail.purchaseUrl
|| purchaseUrl;
botUrl = errorPayload.detail.bot_url
|| errorPayload.detail.botUrl
|| botUrl;
}
} else if (typeof errorPayload?.message === 'string') {
detail = errorPayload.message;
@@ -6644,6 +6768,14 @@
title = errorPayload.title;
}
if (typeof errorPayload?.code === 'string') {
detailCode = detailCode || errorPayload.code;
}
if (typeof errorPayload?.bot_url === 'string' || typeof errorPayload?.botUrl === 'string') {
botUrl = errorPayload.bot_url || errorPayload.botUrl || botUrl;
}
purchaseUrl = purchaseUrl
|| errorPayload?.purchase_url
|| errorPayload?.purchaseUrl
@@ -6652,11 +6784,13 @@
// ignore JSON parsing errors
}
const errorObject = createError(title, detail, response.status);
const normalizedPurchaseUrl = normalizeUrl(purchaseUrl);
if (normalizedPurchaseUrl) {
errorObject.purchaseUrl = normalizedPurchaseUrl;
}
const normalizedBotUrl = normalizeUrl(botUrl);
const errorObject = createError(title, detail, response.status, {
code: detailCode,
purchaseUrl: normalizedPurchaseUrl || null,
botUrl: normalizedBotUrl || null,
});
throw errorObject;
}
@@ -6691,12 +6825,18 @@
currentErrorState = null;
updateErrorTexts();
updateRegisterState();
const errorState = document.getElementById('errorState');
if (errorState) {
errorState.classList.add('hidden');
}
const registerState = document.getElementById('registerState');
if (registerState) {
registerState.classList.add('hidden');
}
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.classList.add('hidden');
@@ -6777,6 +6917,8 @@
);
if (configUrl) {
configPurchaseUrl = configUrl;
updateRegisterState();
updateSubscriptionEmptyState();
}
} catch (error) {
console.warn('Unable to load apps configuration:', error);
@@ -6784,6 +6926,30 @@
}
}
function updateSubscriptionEmptyState() {
const card = document.getElementById('subscriptionEmptyCard');
const userCard = document.querySelector('.user-card');
if (!card) {
return;
}
const hasUser = Boolean(userData?.user);
const hasActive = Boolean(userData?.user?.has_active_subscription);
const shouldShow = hasUser && !hasActive;
card.classList.toggle('hidden', !shouldShow);
if (userCard) {
userCard.classList.toggle('hidden', shouldShow);
}
const actionBtn = document.getElementById('subscriptionEmptyAction');
if (actionBtn) {
const link = getEffectivePurchaseUrl();
actionBtn.disabled = !link;
actionBtn.dataset.link = link || '';
}
}
function renderUserData() {
if (!userData?.user) {
return;
@@ -6799,7 +6965,7 @@
document.getElementById('userAvatar').textContent = avatarChar;
document.getElementById('userName').textContent = fallbackName;
const knownStatuses = ['active', 'trial', 'expired', 'disabled'];
const knownStatuses = ['active', 'trial', 'expired', 'disabled', 'inactive'];
const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase();
const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown';
const statusBadge = document.getElementById('statusBadge');
@@ -6879,6 +7045,7 @@
: autopayLabel;
}
updateSubscriptionEmptyState();
renderSubscriptionPurchaseCard();
renderSubscriptionRenewalCard();
renderSubscriptionSettingsCard();
@@ -10682,7 +10849,7 @@
|| userData.user.subscription_status
|| ''
).toLowerCase();
if (['trial', 'expired', 'disabled'].includes(statusRaw)) {
if (['trial', 'expired', 'disabled', 'inactive'].includes(statusRaw)) {
return false;
}
@@ -15280,9 +15447,22 @@
title: error?.title,
message: error?.message,
purchaseUrl: normalizeUrl(error?.purchaseUrl) || null,
botUrl: normalizeUrl(error?.botUrl) || null,
code: error?.code || null,
};
updateErrorTexts();
document.getElementById('errorState').classList.remove('hidden');
updateRegisterState();
const errorState = document.getElementById('errorState');
const registerState = document.getElementById('registerState');
if (error?.code === 'user_not_registered') {
errorState?.classList.add('hidden');
registerState?.classList.remove('hidden');
} else {
registerState?.classList.add('hidden');
errorState?.classList.remove('hidden');
}
updateActionButtons();
}
@@ -15300,6 +15480,22 @@
openExternalLink(link);
});
const registerBtn = document.getElementById('registerBtn');
if (registerBtn) {
registerBtn.addEventListener('click', () => {
const link = registerBtn.dataset.link || getEffectivePurchaseUrl();
openExternalLink(link);
});
}
const subscriptionEmptyAction = document.getElementById('subscriptionEmptyAction');
if (subscriptionEmptyAction) {
subscriptionEmptyAction.addEventListener('click', () => {
const link = subscriptionEmptyAction.dataset.link || getEffectivePurchaseUrl();
openExternalLink(link);
});
}
const topupButton = document.getElementById('topupBalanceBtn');
if (topupButton) {
topupButton.addEventListener('click', () => {