mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
Revert "feat: surface promo offers in mini app"
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user