Revert "feat: surface promo offers in mini app"

This commit is contained in:
Egor
2025-10-09 04:50:27 +03:00
committed by GitHub
parent 7794a4cc7b
commit 5bd618a799
3 changed files with 123 additions and 975 deletions

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import APIRouter, Depends, HTTPException, status
@@ -9,14 +8,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import (
get_offer_by_id,
list_discount_offers,
mark_offer_claimed,
)
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import PromoGroup, Subscription, Transaction, User
@@ -24,7 +17,6 @@ from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.services.promo_offer_service import promo_offer_service
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
from app.utils.telegram_webapp import (
@@ -37,9 +29,6 @@ from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
MiniAppAutoPromoGroupLevel,
MiniAppPromoOffer,
MiniAppPromoOfferClaimRequest,
MiniAppPromoOfferClaimResponse,
MiniAppPromoGroup,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
@@ -153,53 +142,6 @@ async def _resolve_connected_servers(
return connected_servers
async def _resolve_authorized_user(
db: AsyncSession,
init_data: str,
*,
require_subscription: bool = False,
include_purchase_url: bool = False,
) -> User:
try:
webapp_data = parse_webapp_init_data(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 (require_subscription and not getattr(user, "subscription", None)):
detail: Union[str, Dict[str, str]] = (
"Subscription not found" if require_subscription else "User not found"
)
if include_purchase_url and require_subscription:
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
if purchase_url:
detail = {
"message": "Subscription not found",
"purchase_url": purchase_url,
}
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=detail)
return user
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
remnawave_uuid = getattr(user, "remnawave_uuid", None)
if not remnawave_uuid:
@@ -324,15 +266,44 @@ async def get_subscription_details(
payload: MiniAppSubscriptionRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionResponse:
user = await _resolve_authorized_user(
db,
payload.init_data,
require_subscription=True,
include_purchase_url=True,
)
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)
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=detail,
)
subscription = user.subscription
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
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))
@@ -416,102 +387,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"},
promo_offer_discount_percent=int(
getattr(user, "promo_offer_discount_percent", 0) or 0
),
promo_offer_discount_source=getattr(user, "promo_offer_discount_source", None),
promo_offer_discount_expires_at=getattr(
user,
"promo_offer_discount_expires_at",
None,
),
)
raw_offers = await list_discount_offers(db, user_id=user.id, is_active=True)
now = datetime.utcnow()
template_cache: Dict[int, Optional[Any]] = {}
promo_offers: List[MiniAppPromoOffer] = []
for offer in raw_offers:
expires_at = getattr(offer, "expires_at", None)
if not expires_at or expires_at <= now:
continue
if getattr(offer, "claimed_at", None):
continue
extra_data_raw = getattr(offer, "extra_data", {})
normalized_extra: Dict[str, Any]
if isinstance(extra_data_raw, dict):
normalized_extra = dict(extra_data_raw)
else:
normalized_extra = {}
offer_type = normalized_extra.get("offer_type")
button_text = normalized_extra.get("button_text")
message_text = normalized_extra.get("message_text")
template_id: Optional[int] = None
raw_template_id = normalized_extra.get("template_id")
if raw_template_id is not None:
try:
template_id = int(raw_template_id)
except (TypeError, ValueError):
template_id = None
template = None
if template_id is not None:
if template_id not in template_cache:
template_cache[template_id] = await get_promo_offer_template_by_id(
db,
template_id,
)
template = template_cache.get(template_id)
active_hours_raw = normalized_extra.get("active_discount_hours")
if template:
offer_type = offer_type or template.offer_type
button_text = button_text or template.button_text
message_text = message_text or template.message_text
if (not active_hours_raw) and template.active_discount_hours:
active_hours_raw = template.active_discount_hours
try:
active_hours = int(float(active_hours_raw)) if active_hours_raw is not None else None
except (TypeError, ValueError):
active_hours = None
if template_id is not None:
normalized_extra.setdefault("template_id", template_id)
if offer_type:
normalized_extra["offer_type"] = offer_type
if button_text:
normalized_extra["button_text"] = button_text
if message_text:
normalized_extra["message_text"] = message_text
if active_hours is not None:
normalized_extra["active_discount_hours"] = active_hours
promo_offers.append(
MiniAppPromoOffer(
id=offer.id,
notification_type=str(getattr(offer, "notification_type", "")),
discount_percent=int(getattr(offer, "discount_percent", 0) or 0),
bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0),
expires_at=expires_at,
created_at=getattr(offer, "created_at", now),
updated_at=getattr(offer, "updated_at", now),
effect_type=getattr(offer, "effect_type", "percent_discount") or "percent_discount",
is_active=bool(getattr(offer, "is_active", True)),
offer_type=offer_type,
button_text=button_text,
message_text=message_text,
active_discount_hours=active_hours,
extra_data=normalized_extra,
)
)
promo_offers.sort(key=lambda item: item.expires_at)
return MiniAppSubscriptionResponse(
subscription_id=subscription.id,
remnawave_short_uuid=subscription.remnawave_short_uuid,
@@ -549,132 +426,6 @@ async def get_subscription_details(
subscription_type="trial" if subscription.is_trial else "paid",
autopay_enabled=bool(subscription.autopay_enabled),
branding=settings.get_miniapp_branding(),
promo_offers=promo_offers,
)
@router.post(
"/promo-offers/{offer_id}/claim",
response_model=MiniAppPromoOfferClaimResponse,
)
async def claim_miniapp_promo_offer(
offer_id: int,
payload: MiniAppPromoOfferClaimRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppPromoOfferClaimResponse:
user = await _resolve_authorized_user(db, payload.init_data)
offer = await get_offer_by_id(db, offer_id)
if not offer or offer.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Offer not found")
now = datetime.utcnow()
if getattr(offer, "claimed_at", None) is not None:
return MiniAppPromoOfferClaimResponse(
success=False,
status="already_claimed",
message="Offer already claimed",
)
expires_at = getattr(offer, "expires_at", None)
if not offer.is_active or (expires_at and expires_at <= now):
offer.is_active = False
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Offer expired")
effect_type = (getattr(offer, "effect_type", "percent_discount") or "percent_discount").lower()
extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {}
if effect_type == "test_access":
success, newly_added, access_expires_at, error_code = await promo_offer_service.grant_test_access(
db,
user,
offer,
)
if not success:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
"code": error_code or "test_access_failed",
"message": "Unable to grant test access",
},
)
await mark_offer_claimed(
db,
offer,
details={
"context": "test_access_claim",
"new_squads": newly_added,
"expires_at": access_expires_at.isoformat() if access_expires_at else None,
},
)
return MiniAppPromoOfferClaimResponse(
success=True,
status="test_access_granted",
message="Test access activated",
test_access_expires_at=access_expires_at,
newly_added_squads=newly_added or [],
)
if effect_type == "balance_bonus":
effect_type = "percent_discount"
discount_percent = int(getattr(offer, "discount_percent", 0) or 0)
if discount_percent <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Discount percent is not available",
)
user.promo_offer_discount_percent = discount_percent
user.promo_offer_discount_source = getattr(offer, "notification_type", None)
user.updated_at = now
raw_duration = extra_data.get("active_discount_hours") if isinstance(extra_data, dict) else None
template_id = None
if isinstance(extra_data, dict):
template_id = extra_data.get("template_id")
if (raw_duration in (None, "", 0, "0")) and template_id is not None:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (TypeError, ValueError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(float(raw_duration)) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
discount_expires_at = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat()
if discount_expires_at
else None,
},
)
await db.refresh(user)
return MiniAppPromoOfferClaimResponse(
success=True,
status="discount_claimed",
message="Discount activated",
discount_percent=discount_percent,
discount_expires_at=discount_expires_at,
)
def _safe_int(value: Any) -> int:

View File

@@ -34,26 +34,6 @@ class MiniAppSubscriptionUser(BaseModel):
traffic_limit_label: str
lifetime_used_traffic_gb: float = 0.0
has_active_subscription: bool = False
promo_offer_discount_percent: int = 0
promo_offer_discount_source: Optional[str] = None
promo_offer_discount_expires_at: Optional[datetime] = None
class MiniAppPromoOffer(BaseModel):
id: int
notification_type: str
discount_percent: int = 0
bonus_amount_kopeks: int = 0
expires_at: datetime
created_at: datetime
updated_at: datetime
effect_type: str
is_active: bool = True
offer_type: Optional[str] = None
button_text: Optional[str] = None
message_text: Optional[str] = None
active_discount_hours: Optional[int] = None
extra_data: Dict[str, Any] = Field(default_factory=dict)
class MiniAppPromoGroup(BaseModel):
@@ -137,19 +117,4 @@ class MiniAppSubscriptionResponse(BaseModel):
subscription_type: str
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None
promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
class MiniAppPromoOfferClaimRequest(BaseModel):
init_data: str = Field(..., alias="initData")
class MiniAppPromoOfferClaimResponse(BaseModel):
success: bool = True
status: str
message: Optional[str] = None
discount_percent: Optional[int] = None
discount_expires_at: Optional[datetime] = None
test_access_expires_at: Optional[datetime] = None
newly_added_squads: List[str] = Field(default_factory=list)

View File

@@ -397,168 +397,6 @@
padding: 0 16px 16px;
}
/* Promo Offer Banner */
.promo-offer-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
}
.promo-offer-card {
position: relative;
padding: 18px 20px;
border-radius: var(--radius-lg);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 16px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.promo-offer-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.promo-offer-card-active {
background: linear-gradient(140deg, rgba(var(--primary-rgb), 0.18), rgba(var(--primary-rgb), 0.42));
color: var(--text-primary);
border-color: rgba(var(--primary-rgb), 0.4);
box-shadow: var(--shadow-md);
}
.promo-offer-card-header {
display: flex;
align-items: center;
gap: 14px;
}
.promo-offer-icon {
width: 42px;
height: 42px;
border-radius: 14px;
background: rgba(var(--primary-rgb), 0.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.promo-offer-card-active .promo-offer-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.promo-offer-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.promo-offer-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.promo-offer-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.promo-offer-card-active .promo-offer-subtitle {
color: rgba(255, 255, 255, 0.85);
}
.promo-offer-card-body {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.promo-offer-highlight {
font-size: 32px;
font-weight: 800;
letter-spacing: 0.5px;
color: var(--primary);
}
.promo-offer-card-active .promo-offer-highlight {
color: var(--tg-theme-button-text-color);
background: rgba(255, 255, 255, 0.16);
padding: 6px 14px;
border-radius: var(--radius);
}
.promo-offer-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.promo-offer-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: var(--radius);
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
font-weight: 600;
font-size: 13px;
}
.promo-offer-card-active .promo-offer-badge {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.promo-offer-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.promo-offer-timer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.promo-offer-timer-label {
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary);
font-weight: 600;
}
.promo-offer-timer-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.promo-offer-card-active .promo-offer-timer-label,
.promo-offer-card-active .promo-offer-timer-value {
color: rgba(255, 255, 255, 0.9);
}
.promo-offer-action {
min-width: 160px;
white-space: nowrap;
}
.promo-offer-action:disabled {
opacity: 0.7;
}
/* User Card Specific */
.user-card {
background: linear-gradient(135deg, var(--bg-secondary), rgba(var(--primary-rgb), 0.05));
@@ -1699,26 +1537,6 @@
color: var(--primary);
}
:root[data-theme="dark"] .promo-offer-card {
background: rgba(30, 41, 59, 0.78);
border-color: rgba(148, 163, 184, 0.28);
box-shadow: var(--shadow-md);
}
:root[data-theme="dark"] .promo-offer-card-active {
background: linear-gradient(145deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.55));
border-color: rgba(var(--primary-rgb), 0.45);
}
:root[data-theme="dark"] .promo-offer-card-active .promo-offer-highlight {
background: rgba(15, 23, 42, 0.35);
}
:root[data-theme="dark"] .promo-offer-badge {
background: rgba(var(--primary-rgb), 0.22);
color: #fff;
}
:root[data-theme="dark"] body,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .user-card,
@@ -1778,8 +1596,6 @@
</button>
</div>
<div class="promo-offer-container hidden" id="promoOfferContainer" aria-live="polite"></div>
<!-- Header -->
<div class="header animate-in">
<div class="logo-container">
@@ -2205,27 +2021,6 @@
'promo.periods.title_short': 'Periods',
'promo.periods.label': '{months} mo',
'promo.periods.month_suffix': 'mo',
'promo.offer.active.title': 'Active personal discount',
'promo.offer.active.subtitle': 'Applied automatically during payment',
'promo.offer.active.timer': 'Valid for',
'promo.offer.pending.subtitle': 'Activate to secure your bonus.',
'promo.offer.timer.pending': 'Expires in',
'promo.offer.timer.expired': 'Expired',
'promo.offer.button.accept': 'Activate',
'promo.offer.button.loading': 'Activating…',
'promo.offer.button.expired': 'Expired',
'promo.offer.type.generic': 'Special offer',
'promo.offer.type.extend': 'Renewal discount',
'promo.offer.type.purchase': 'Welcome back discount',
'promo.offer.type.test_access': 'Test access',
'promo.offer.badge.discount': '{value}',
'promo.offer.badge.active_hours': '{hours} after activation',
'promo.offer.badge.test_access': 'Test servers',
'promo.offer.claim.success.title': 'Success',
'promo.offer.claim.success.discount': 'Discount activated! It will be applied automatically at checkout.',
'promo.offer.claim.success.test_access': 'Test access activated! New servers are now available.',
'promo.offer.claim.error.title': 'Error',
'promo.offer.claim.error': 'Failed to activate the offer. Please try again later.',
'apps.title': 'Installation guide',
'apps.no_data': 'No installation guide available for this platform yet.',
'apps.featured': 'Recommended',
@@ -2311,27 +2106,6 @@
'promo.periods.title_short': 'Периоды',
'promo.periods.label': '{months} мес.',
'promo.periods.month_suffix': 'мес.',
'promo.offer.active.title': 'Активная скидка',
'promo.offer.active.subtitle': 'Применится автоматически при оплате',
'promo.offer.active.timer': 'Действует ещё',
'promo.offer.pending.subtitle': 'Активируйте, чтобы закрепить бонус.',
'promo.offer.timer.pending': 'Истекает через',
'promo.offer.timer.expired': 'Истекло',
'promo.offer.button.accept': 'Принять',
'promo.offer.button.loading': 'Активация…',
'promo.offer.button.expired': 'Истекло',
'promo.offer.type.generic': 'Специальное предложение',
'promo.offer.type.extend': 'Скидка на продление',
'promo.offer.type.purchase': 'Скидка на возвращение',
'promo.offer.type.test_access': 'Тестовый доступ',
'promo.offer.badge.discount': '{value}',
'promo.offer.badge.active_hours': '{hours} после активации',
'promo.offer.badge.test_access': 'Тестовые сервера',
'promo.offer.claim.success.title': 'Готово',
'promo.offer.claim.success.discount': 'Скидка активирована! Она автоматически применится при оплате.',
'promo.offer.claim.success.test_access': 'Тестовый доступ активирован! Проверьте список серверов.',
'promo.offer.claim.error.title': 'Ошибка',
'promo.offer.claim.error': 'Не удалось активировать предложение. Попробуйте позже.',
'apps.title': 'Инструкция по установке',
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
'apps.featured': 'Рекомендуем',
@@ -2476,9 +2250,6 @@
let preferredLanguage = 'en';
let languageLockedByUser = false;
let currentErrorState = null;
let promoTimerIntervalId = null;
let promoClaimInFlight = false;
let reloadInFlight = false;
function resolveLanguage(lang) {
if (!lang) {
@@ -2547,95 +2318,6 @@
return trimmed.length ? trimmed : null;
}
function formatString(template, values = {}) {
if (typeof template !== 'string') {
return template ?? '';
}
return template.replace(/\{(\w+)\}/g, (_, key) => {
if (Object.prototype.hasOwnProperty.call(values, key)) {
const rawValue = values[key];
return rawValue === undefined || rawValue === null ? '' : String(rawValue);
}
return `{${key}}`;
});
}
function parseDate(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatPromoDuration(milliseconds) {
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
return t('promo.offer.timer.expired');
}
const totalSeconds = Math.floor(milliseconds / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const lang = (preferredLanguage || 'en').toLowerCase();
const labels = lang.startsWith('ru')
? { day: 'д', hour: 'ч', minute: 'м', second: 'с' }
: { day: 'd', hour: 'h', minute: 'm', second: 's' };
const parts = [];
if (days > 0) {
parts.push(`${days}${labels.day}`);
}
if (hours > 0 || days > 0) {
parts.push(`${hours}${labels.hour}`);
}
parts.push(`${minutes}${labels.minute}`);
if (days === 0 && hours === 0) {
parts.push(`${seconds}${labels.second}`);
}
return parts.slice(0, 3).join(' ');
}
function stopPromoTimerUpdates() {
if (promoTimerIntervalId) {
clearInterval(promoTimerIntervalId);
promoTimerIntervalId = null;
}
}
function updatePromoCountdowns() {
const now = Date.now();
document.querySelectorAll('[data-promo-countdown]').forEach(element => {
const target = element.getAttribute('data-promo-countdown');
if (!target) {
return;
}
const expiresAt = Date.parse(target);
if (Number.isNaN(expiresAt)) {
element.textContent = t('promo.offer.timer.expired');
element.removeAttribute('data-promo-countdown');
return;
}
const remaining = expiresAt - now;
if (remaining <= 0) {
element.textContent = t('promo.offer.timer.expired');
const actionButton = element.closest('.promo-offer-card')?.querySelector('[data-action="claim-promo-offer"]');
if (actionButton) {
actionButton.disabled = true;
actionButton.textContent = t('promo.offer.button.expired');
}
element.removeAttribute('data-promo-countdown');
return;
}
element.textContent = formatPromoDuration(remaining);
});
}
function startPromoTimerUpdates() {
stopPromoTimerUpdates();
updatePromoCountdowns();
promoTimerIntervalId = window.setInterval(updatePromoCountdowns, 1000);
}
function getEffectivePurchaseUrl() {
const candidates = [
currentErrorState?.purchaseUrl,
@@ -2754,128 +2436,6 @@
return error;
}
async function fetchSubscriptionData() {
const initData = tg.initData || '';
if (!initData) {
throw createError('Authorization Error', 'Missing Telegram ID');
}
const response = await fetch('/miniapp/subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
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';
let purchaseUrl = null;
try {
const errorPayload = await response.json();
if (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
}
const errorObject = createError(title, detail, response.status);
const normalizedPurchaseUrl = normalizeUrl(purchaseUrl);
if (normalizedPurchaseUrl) {
errorObject.purchaseUrl = normalizedPurchaseUrl;
}
throw errorObject;
}
return response.json();
}
function applyUserDataPayload(data, options = {}) {
if (!data) {
return;
}
userData = data;
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);
}
currentErrorState = null;
document.getElementById('errorState')?.classList.add('hidden');
const responseLanguage = resolveLanguage(userData?.user?.language);
if (responseLanguage && !languageLockedByUser) {
preferredLanguage = responseLanguage;
}
detectPlatform();
setActivePlatformButton();
refreshAfterLanguageChange();
if (options.animateCards) {
document.querySelectorAll('.card').forEach((card, index) => {
setTimeout(() => {
card.classList.add('animate-in');
}, index * 100);
});
}
}
async function reloadSubscriptionData(options = {}) {
if (reloadInFlight) {
return;
}
reloadInFlight = true;
try {
const data = await fetchSubscriptionData();
applyUserDataPayload(data, { animateCards: Boolean(options.animateCards) });
} catch (error) {
console.error('Failed to reload subscription data:', error);
if (options.showError !== false) {
showPopup(
error?.message || t('promo.offer.claim.error') || 'Unable to refresh data.',
t('promo.offer.claim.error.title') || 'Error'
);
}
} finally {
reloadInFlight = false;
}
}
async function init() {
try {
const telegramUser = tg.initDataUnsafe?.user;
@@ -2889,12 +2449,96 @@
}
await loadAppsConfig();
const data = await fetchSubscriptionData();
const initData = tg.initData || '';
if (!initData) {
throw createError('Authorization Error', 'Missing Telegram ID');
}
const response = await fetch('/miniapp/subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
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';
let purchaseUrl = null;
try {
const errorPayload = await response.json();
if (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
}
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);
}
const responseLanguage = resolveLanguage(userData?.user?.language);
if (responseLanguage && !languageLockedByUser) {
preferredLanguage = responseLanguage;
}
detectPlatform();
setActivePlatformButton();
refreshAfterLanguageChange();
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
applyUserDataPayload(data, { animateCards: true });
// Add animation to cards
document.querySelectorAll('.card').forEach((card, index) => {
setTimeout(() => {
card.classList.add('animate-in');
}, index * 100);
});
} catch (error) {
console.error('Initialization error:', error);
showError(error);
@@ -2931,205 +2575,6 @@
}
}
function getPromoOfferTypeConfig(offer) {
const rawType = String(offer?.offer_type || offer?.effect_type || '').toLowerCase();
const mapping = {
test_access: { icon: '🧪', title: t('promo.offer.type.test_access'), id: 'test_access' },
extend_discount: { icon: '💎', title: t('promo.offer.type.extend'), id: 'extend_discount' },
purchase_discount: { icon: '🎯', title: t('promo.offer.type.purchase'), id: 'purchase_discount' }
};
return mapping[rawType] || { icon: '🎁', title: t('promo.offer.type.generic'), id: 'generic' };
}
function createActivePromoCard({ percent, expiresAt }) {
const card = document.createElement('div');
card.className = 'promo-offer-card promo-offer-card-active';
const discountText = formatString(t('promo.offer.badge.discount'), { value: `${percent}%` });
const timerValue = formatPromoDuration(expiresAt.getTime() - Date.now());
card.innerHTML = `
<div class="promo-offer-card-header">
<div class="promo-offer-icon">⚡</div>
<div class="promo-offer-header-text">
<div class="promo-offer-title">${escapeHtml(t('promo.offer.active.title'))}</div>
<div class="promo-offer-subtitle">${escapeHtml(t('promo.offer.active.subtitle'))}</div>
</div>
</div>
<div class="promo-offer-card-body">
<div class="promo-offer-highlight">${escapeHtml(discountText)}</div>
<div class="promo-offer-timer">
<span class="promo-offer-timer-label">${escapeHtml(t('promo.offer.active.timer'))}</span>
<span class="promo-offer-timer-value" data-promo-countdown="${expiresAt.toISOString()}">${escapeHtml(timerValue)}</span>
</div>
</div>
`;
return card;
}
function createPendingPromoCard(offer, expiresAt) {
const card = document.createElement('div');
card.className = 'promo-offer-card promo-offer-card-pending';
const typeConfig = getPromoOfferTypeConfig(offer);
const badges = [];
const percent = Number(offer?.discount_percent) || 0;
if (percent > 0) {
badges.push(formatString(t('promo.offer.badge.discount'), { value: `${percent}%` }));
}
let activeHours = offer?.active_discount_hours;
if (activeHours == null && offer?.extra_data && typeof offer.extra_data === 'object') {
activeHours = offer.extra_data.active_discount_hours;
}
let normalizedHours = Number(activeHours);
if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
normalizedHours = null;
}
if (normalizedHours) {
const isRussian = (preferredLanguage || 'en').toLowerCase().startsWith('ru');
const isWholeDays = normalizedHours >= 24 && normalizedHours % 24 === 0;
const value = isWholeDays
? `${Math.round(normalizedHours / 24)}${isRussian ? 'д' : 'd'}`
: `${normalizedHours}${isRussian ? 'ч' : 'h'}`;
badges.push(formatString(t('promo.offer.badge.active_hours'), { hours: value }));
}
const effectType = String(offer?.effect_type || '').toLowerCase();
if (effectType === 'test_access' || typeConfig.id === 'test_access') {
badges.push(t('promo.offer.badge.test_access'));
}
const badgeHtml = badges.length
? `<div class="promo-offer-badges">${badges.map(badge => `<span class="promo-offer-badge">${escapeHtml(badge)}</span>`).join('')}</div>`
: '';
let buttonLabel = offer?.button_text;
if (!buttonLabel && offer?.extra_data && typeof offer.extra_data === 'object') {
buttonLabel = offer.extra_data.button_text;
}
if (!buttonLabel) {
buttonLabel = t('promo.offer.button.accept');
}
const timerValue = formatPromoDuration(expiresAt.getTime() - Date.now());
card.innerHTML = `
<div class="promo-offer-card-header">
<div class="promo-offer-icon">${escapeHtml(typeConfig.icon)}</div>
<div class="promo-offer-header-text">
<div class="promo-offer-title">${escapeHtml(typeConfig.title)}</div>
<div class="promo-offer-subtitle">${escapeHtml(t('promo.offer.pending.subtitle'))}</div>
</div>
</div>
${badgeHtml}
<div class="promo-offer-card-footer">
<div class="promo-offer-timer">
<span class="promo-offer-timer-label">${escapeHtml(t('promo.offer.timer.pending'))}</span>
<span class="promo-offer-timer-value" data-promo-countdown="${expiresAt.toISOString()}">${escapeHtml(timerValue)}</span>
</div>
<button class="btn btn-primary promo-offer-action" data-action="claim-promo-offer" data-offer-id="${offer.id}">
${escapeHtml(buttonLabel)}
</button>
</div>
`;
return card;
}
function renderPromoOffers() {
const container = document.getElementById('promoOfferContainer');
if (!container) {
return;
}
const offers = Array.isArray(userData?.promo_offers) ? userData.promo_offers : [];
const activePercent = Number(userData?.user?.promo_offer_discount_percent) || 0;
const activeExpiresAt = parseDate(userData?.user?.promo_offer_discount_expires_at);
const now = new Date();
const hasActiveDiscount = activePercent > 0 && activeExpiresAt && activeExpiresAt > now;
const fragment = document.createDocumentFragment();
if (hasActiveDiscount) {
fragment.appendChild(createActivePromoCard({ percent: activePercent, expiresAt: activeExpiresAt }));
}
offers.forEach(offer => {
const expiresAt = parseDate(offer?.expires_at);
if (!expiresAt || expiresAt <= now) {
return;
}
fragment.appendChild(createPendingPromoCard(offer, expiresAt));
});
container.innerHTML = '';
if (!fragment.childNodes.length) {
container.classList.add('hidden');
stopPromoTimerUpdates();
return;
}
container.appendChild(fragment);
container.classList.remove('hidden');
startPromoTimerUpdates();
}
async function claimPromoOffer(offerId, triggerButton) {
if (!offerId || promoClaimInFlight) {
return;
}
promoClaimInFlight = true;
const button = triggerButton || null;
let originalText = '';
if (button) {
originalText = button.textContent;
button.disabled = true;
button.textContent = t('promo.offer.button.loading');
}
try {
const response = await fetch(`/miniapp/promo-offers/${offerId}/claim`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData: tg.initData || '' })
});
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok || !payload?.success) {
const detail = payload?.detail;
const message = payload?.message
|| (typeof detail === 'string' ? detail : detail?.message)
|| t('promo.offer.claim.error');
showPopup(message, t('promo.offer.claim.error.title'));
throw Object.assign(new Error(message), { handled: true });
}
const status = payload.status || 'discount_claimed';
if (status === 'test_access_granted') {
showPopup(t('promo.offer.claim.success.test_access'), t('promo.offer.claim.success.title'));
} else {
showPopup(t('promo.offer.claim.success.discount'), t('promo.offer.claim.success.title'));
}
await reloadSubscriptionData({ animateCards: false, showError: false });
} catch (error) {
console.error('Failed to claim promo offer:', error);
if (!error?.handled) {
showPopup(error?.message || t('promo.offer.claim.error'), t('promo.offer.claim.error.title'));
}
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText || t('promo.offer.button.accept');
}
promoClaimInFlight = false;
}
}
function renderUserData() {
if (!userData?.user) {
return;
@@ -3225,7 +2670,6 @@
: autopayLabel;
}
renderPromoOffers();
renderPromoSection();
renderBalanceSection();
renderTransactionHistory();
@@ -4290,18 +3734,6 @@
updateActionButtons();
}
document.getElementById('promoOfferContainer')?.addEventListener('click', event => {
const button = event.target.closest('[data-action="claim-promo-offer"]');
if (!button) {
return;
}
const offerId = Number(button.dataset.offerId);
if (!Number.isFinite(offerId)) {
return;
}
claimPromoOffer(offerId, button);
});
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentPlatform = btn.dataset.platform;