From 051048e0ae733ab78b4a963b1d3fab6e14e800e2 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 9 Oct 2025 04:50:46 +0300 Subject: [PATCH] Add promo offer banners to mini app --- app/handlers/subscription.py | 134 ++-- app/services/promo_offer_service.py | 127 ++++ app/webapi/routes/miniapp.py | 205 +++++- app/webapi/schemas/miniapp.py | 31 + miniapp/index.html | 1036 +++++++++++++++++++++++++-- 5 files changed, 1358 insertions(+), 175 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 9454217b..933d58f1 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -9,11 +9,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id +from app.database.crud.discount_offer import get_offer_by_id from app.database.crud.subscription import ( create_trial_subscription, create_paid_subscription, add_subscription_traffic, add_subscription_devices, @@ -5242,35 +5238,13 @@ async def claim_discount_offer( ) return - now = datetime.utcnow() - if offer.claimed_at is not None: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), - show_alert=True, - ) - return + result = await promo_offer_service.claim_offer(db, db_user, offer) - if not offer.is_active or offer.expires_at <= now: - offer.is_active = False - await db.commit() - await callback.answer( - texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), - show_alert=True, - ) - return + if not result.success: + error_code = (result.error_code or "unknown").lower() + effect_type = result.effect_type or "percent_discount" - effect_type = (offer.effect_type or "percent_discount").lower() - if effect_type == "balance_bonus": - effect_type = "percent_discount" - - if effect_type == "test_access": - success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( - db, - db_user, - offer, - ) - - if not success: + if effect_type == "test_access": if error_code == "subscription_missing": error_message = texts.get( "TEST_ACCESS_NO_SUBSCRIPTION", @@ -5296,19 +5270,35 @@ async def claim_discount_offer( "TEST_ACCESS_UNKNOWN_ERROR", "❌ Не удалось активировать предложение. Попробуйте позже.", ) - await callback.answer(error_message, show_alert=True) - return + else: + if error_code == "already_claimed": + error_message = texts.get( + "DISCOUNT_CLAIM_ALREADY", + "ℹ️ Скидка уже была активирована", + ) + elif error_code in {"expired", "inactive"}: + error_message = texts.get( + "DISCOUNT_CLAIM_EXPIRED", + "⚠️ Время действия предложения истекло", + ) + elif error_code == "no_discount": + error_message = texts.get( + "DISCOUNT_CLAIM_ERROR", + "❌ Не удалось активировать скидку. Попробуйте позже.", + ) + else: + error_message = texts.get( + "DISCOUNT_CLAIM_ERROR", + "❌ Не удалось активировать скидку. Попробуйте позже.", + ) - await mark_offer_claimed( - db, - offer, - details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": expires_at.isoformat() if expires_at else None, - }, - ) + await callback.answer(error_message, show_alert=True) + return + effect_type = (result.effect_type or "percent_discount").lower() + + if effect_type == "test_access": + expires_at = result.test_access_expires_at expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else "" success_message = texts.get( "TEST_ACCESS_ACTIVATED_MESSAGE", @@ -5330,52 +5320,11 @@ async def claim_discount_offer( await callback.message.answer(success_message, reply_markup=back_keyboard) return - discount_percent = int(offer.discount_percent or 0) - if discount_percent <= 0: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."), - show_alert=True, - ) - return - - db_user.promo_offer_discount_percent = discount_percent - db_user.promo_offer_discount_source = offer.notification_type - db_user.updated_at = now - + discount_percent = result.discount_percent + discount_expires_at = result.discount_expires_at + duration_hours = result.discount_duration_hours extra_data = offer.extra_data or {} - raw_duration = extra_data.get("active_discount_hours") - template_id = extra_data.get("template_id") - - if raw_duration in (None, "") and template_id: - try: - template = await get_promo_offer_template_by_id(db, int(template_id)) - except (ValueError, TypeError): - template = None - if template and template.active_discount_hours: - raw_duration = template.active_discount_hours - - try: - duration_hours = int(raw_duration) if raw_duration is not None else None - except (TypeError, ValueError): - duration_hours = None - - if duration_hours and duration_hours > 0: - discount_expires_at = now + timedelta(hours=duration_hours) - else: - discount_expires_at = None - - db_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(db_user) + now = datetime.utcnow() success_template = texts.get( "DISCOUNT_CLAIM_SUCCESS", @@ -5388,6 +5337,15 @@ async def claim_discount_offer( format_values: Dict[str, Any] = {"percent": discount_percent} + if duration_hours is None and isinstance(extra_data, dict): + raw_duration = extra_data.get("active_discount_hours") or extra_data.get("duration_hours") + try: + parsed_duration = int(raw_duration) if raw_duration is not None else None + except (TypeError, ValueError): + parsed_duration = None + if parsed_duration and parsed_duration > 0: + duration_hours = parsed_duration + if duration_hours and duration_hours > 0: format_values.setdefault("hours", duration_hours) format_values.setdefault("duration_hours", duration_hours) diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py index 11de8c5e..c23fcf2f 100644 --- a/app/services/promo_offer_service.py +++ b/app/services/promo_offer_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Dict, List, Optional, Sequence, Tuple @@ -8,6 +9,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.database.crud.discount_offer import mark_offer_claimed +from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.models import ( DiscountOffer, Subscription, @@ -20,6 +23,18 @@ from app.database.crud.promo_offer_log import log_promo_offer_action logger = logging.getLogger(__name__) +@dataclass +class PromoOfferClaimOutcome: + success: bool + effect_type: str + discount_percent: int = 0 + discount_expires_at: Optional[datetime] = None + discount_duration_hours: Optional[int] = None + test_access_expires_at: Optional[datetime] = None + newly_added_squads: List[str] = field(default_factory=list) + error_code: Optional[str] = None + + class PromoOfferService: def __init__(self) -> None: self.subscription_service = SubscriptionService() @@ -131,6 +146,118 @@ class PromoOfferService: return True, newly_added, expires_at, "ok" + async def claim_offer( + self, + db: AsyncSession, + user: User, + offer: DiscountOffer, + ) -> PromoOfferClaimOutcome: + effect_type_raw = (offer.effect_type or "percent_discount").lower() + effect_type = "percent_discount" if effect_type_raw == "balance_bonus" else effect_type_raw + + now = datetime.utcnow() + + if offer.claimed_at is not None: + return PromoOfferClaimOutcome(False, effect_type, error_code="already_claimed") + + if not offer.is_active: + return PromoOfferClaimOutcome(False, effect_type, error_code="inactive") + + if offer.expires_at <= now: + offer.is_active = False + await db.commit() + return PromoOfferClaimOutcome(False, effect_type, error_code="expired") + + if effect_type == "test_access": + success, newly_added, expires_at, error_code = await self.grant_test_access( + db, + user, + offer, + ) + + if not success: + return PromoOfferClaimOutcome( + False, + effect_type, + error_code=error_code or "unknown", + ) + + await mark_offer_claimed( + db, + offer, + details={ + "context": "test_access_claim", + "new_squads": newly_added, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + + await db.refresh(user) + + return PromoOfferClaimOutcome( + True, + effect_type, + newly_added_squads=newly_added or [], + test_access_expires_at=expires_at, + ) + + try: + discount_percent = int(offer.discount_percent or 0) + except (TypeError, ValueError): + discount_percent = 0 + + if discount_percent <= 0: + return PromoOfferClaimOutcome(False, effect_type, error_code="no_discount") + + user.promo_offer_discount_percent = discount_percent + user.promo_offer_discount_source = offer.notification_type + user.updated_at = now + + extra_data = offer.extra_data or {} + raw_duration = extra_data.get("active_discount_hours") + template_id = extra_data.get("template_id") + + if (raw_duration in (None, "")) and template_id: + 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(raw_duration) if raw_duration is not None else None + except (TypeError, ValueError): + duration_hours = None + + discount_expires_at: Optional[datetime] = 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 PromoOfferClaimOutcome( + True, + effect_type, + discount_percent=discount_percent, + discount_expires_at=discount_expires_at, + discount_duration_hours=duration_hours if duration_hours and duration_hours > 0 else None, + ) + async def cleanup_expired_test_access(self, db: AsyncSession) -> int: now = datetime.utcnow() result = await db.execute( diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 09a130d1..0ac3e8b2 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,6 +1,9 @@ from __future__ import annotations +import html import logging +import re +from datetime import datetime from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException, status @@ -8,11 +11,14 @@ 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 +from app.database.crud.promo_offer_template import get_promo_offer_template_by_id 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.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 +from app.services.promo_offer_service import promo_offer_service from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, @@ -29,7 +35,10 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, MiniAppAutoPromoGroupLevel, + MiniAppPromoOffer, MiniAppPromoGroup, + MiniAppPromoOfferClaimRequest, + MiniAppPromoOfferClaimResponse, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -218,6 +227,129 @@ def _is_remnawave_configured() -> bool: return bool(params.get("base_url") and params.get("api_key")) +def _sanitize_offer_message(text: Optional[str]) -> Optional[str]: + if not text: + return None + + normalized = text.replace("\r\n", "\n") + normalized = re.sub(r"", "\n", normalized, flags=re.IGNORECASE) + stripped = re.sub(r"<[^>]+>", "", normalized) + unescaped = html.unescape(stripped) + cleaned = unescaped.strip() + return cleaned or None + + +def _parse_init_payload(init_data: str) -> int: + 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: + return int(telegram_user["id"]) + except (TypeError, ValueError) as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from error + + +async def _load_available_promo_offers( + db: AsyncSession, + user: User, +) -> List[MiniAppPromoOffer]: + offers = await list_discount_offers( + db, + user_id=user.id, + is_active=True, + limit=20, + ) + + now = datetime.utcnow() + templates_cache: Dict[int, Optional[Any]] = {} + result: List[MiniAppPromoOffer] = [] + + for offer in offers: + if offer.claimed_at is not None: + continue + if offer.expires_at <= now: + continue + + extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {} + + template_id: Optional[int] = None + raw_template_id = extra_data.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 in templates_cache: + template = templates_cache[template_id] + else: + template = await get_promo_offer_template_by_id(db, template_id) + templates_cache[template_id] = template + + name = None + button_text = None + message_text = None + + if template is not None: + name = template.name + button_text = template.button_text + message_text = template.message_text + else: + name = extra_data.get("title") or extra_data.get("name") + button_text = extra_data.get("button_text") or extra_data.get("buttonText") + message_text = ( + extra_data.get("message_text") + or extra_data.get("messageText") + or extra_data.get("text") + ) + + sanitized_message = _sanitize_offer_message(message_text) + + try: + discount_percent = int(offer.discount_percent or 0) + except (TypeError, ValueError): + discount_percent = 0 + + try: + bonus_amount = int(offer.bonus_amount_kopeks or 0) + except (TypeError, ValueError): + bonus_amount = 0 + + result.append( + MiniAppPromoOffer( + id=offer.id, + template_id=template_id, + name=name, + message=sanitized_message, + button_text=button_text, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount, + expires_at=offer.expires_at, + effect_type=offer.effect_type or "percent_discount", + extra_data=extra_data, + ) + ) + + return result + + def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: return MiniAppTransaction( id=transaction.id, @@ -266,28 +398,7 @@ async def get_subscription_details( payload: MiniAppSubscriptionRequest, db: AsyncSession = Depends(get_db_session), ) -> MiniAppSubscriptionResponse: - try: - webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(error), - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user identifier", - ) from None + telegram_id = _parse_init_payload(payload.init_data) user = await get_user_by_telegram_id(db, telegram_id) purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() @@ -361,6 +472,8 @@ async def get_subscription_details( ) ) + available_promo_offers = await _load_available_promo_offers(db, user) + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -387,6 +500,14 @@ 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, + ), ) return MiniAppSubscriptionResponse( @@ -420,6 +541,7 @@ async def get_subscription_details( else None ), auto_assign_promo_groups=auto_promo_levels, + promo_offers=available_promo_offers, total_spent_kopeks=total_spent_kopeks, total_spent_rubles=round(total_spent_kopeks / 100, 2), total_spent_label=settings.format_price(total_spent_kopeks), @@ -428,6 +550,45 @@ async def get_subscription_details( branding=settings.get_miniapp_branding(), ) + +@router.post( + "/promo-offers/{offer_id}/claim", + response_model=MiniAppPromoOfferClaimResponse, +) +async def claim_promo_offer( + offer_id: int, + payload: MiniAppPromoOfferClaimRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppPromoOfferClaimResponse: + telegram_id = _parse_init_payload(payload.init_data) + + user = await get_user_by_telegram_id(db, telegram_id) + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + 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, "Promo offer not found") + + result = await promo_offer_service.claim_offer(db, user, offer) + + if not result.success: + return MiniAppPromoOfferClaimResponse( + success=False, + effect_type=result.effect_type, + error_code=result.error_code or "unknown", + ) + + return MiniAppPromoOfferClaimResponse( + success=True, + effect_type=result.effect_type, + discount_percent=result.discount_percent or None, + discount_expires_at=result.discount_expires_at, + test_access_expires_at=result.test_access_expires_at, + newly_added_squads=result.newly_added_squads, + ) + + def _safe_int(value: Any) -> int: try: return int(value) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 360e6fa5..a0450992 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -34,6 +34,9 @@ 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 MiniAppPromoGroup(BaseModel): @@ -61,6 +64,19 @@ class MiniAppAutoPromoGroupLevel(BaseModel): apply_discounts_to_addons: bool = True +class MiniAppPromoOffer(BaseModel): + id: int + template_id: Optional[int] = None + name: Optional[str] = None + message: Optional[str] = None + button_text: Optional[str] = None + discount_percent: int = 0 + bonus_amount_kopeks: int = 0 + expires_at: datetime + effect_type: str = "percent_discount" + extra_data: Dict[str, Any] = Field(default_factory=dict) + + class MiniAppConnectedServer(BaseModel): uuid: str name: str @@ -111,6 +127,7 @@ class MiniAppSubscriptionResponse(BaseModel): transactions: List[MiniAppTransaction] = Field(default_factory=list) promo_group: Optional[MiniAppPromoGroup] = None auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list) + promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list) total_spent_kopeks: int = 0 total_spent_rubles: float = 0.0 total_spent_label: Optional[str] = None @@ -118,3 +135,17 @@ class MiniAppSubscriptionResponse(BaseModel): autopay_enabled: bool = False branding: Optional[MiniAppBranding] = None + +class MiniAppPromoOfferClaimRequest(BaseModel): + init_data: str = Field(..., alias="initData") + + +class MiniAppPromoOfferClaimResponse(BaseModel): + success: bool = True + effect_type: 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) + error_code: Optional[str] = None + diff --git a/miniapp/index.html b/miniapp/index.html index 7161ee85..da1f2429 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -55,6 +55,25 @@ --shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55); } + :root[data-theme="dark"] .promo-offer-active { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.32), rgba(var(--primary-rgb), 0.62)); + color: #f8fafc; + } + + :root[data-theme="dark"] .promo-offer-active-timer-value { + background: rgba(15, 23, 42, 0.45); + } + + :root[data-theme="dark"] .promo-offer-card { + background: rgba(30, 41, 59, 0.92); + border-color: rgba(148, 163, 184, 0.3); + } + + :root[data-theme="dark"] .promo-offer-tag.neutral { + background: rgba(148, 163, 184, 0.2); + color: var(--text-primary); + } + :root[data-theme="light"] { color-scheme: light; } @@ -84,6 +103,212 @@ padding-bottom: 32px; } + .promo-offer-container { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; + } + + .promo-offer-active { + position: relative; + border-radius: var(--radius-lg); + padding: 20px; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(var(--primary-rgb), 0.42)); + color: #ffffff; + overflow: hidden; + box-shadow: var(--shadow-md); + } + + .promo-offer-active::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.35), transparent 55%); + pointer-events: none; + } + + .promo-offer-active-header { + position: relative; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + } + + .promo-offer-active-title { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.02em; + } + + .promo-offer-active-subtitle { + font-size: 13px; + opacity: 0.85; + margin-top: 6px; + } + + .promo-offer-active-percent { + font-size: 34px; + font-weight: 800; + letter-spacing: 0.02em; + line-height: 1; + text-align: right; + } + + .promo-offer-active-timer { + display: flex; + align-items: center; + gap: 10px; + margin-top: 16px; + position: relative; + z-index: 1; + } + + .promo-offer-active-timer-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + opacity: 0.75; + } + + .promo-offer-active-timer-value { + font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 18px; + font-weight: 700; + padding: 6px 12px; + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.22); + letter-spacing: 0.08em; + } + + .promo-offer-active-meta { + margin-top: 14px; + font-size: 13px; + opacity: 0.9; + position: relative; + z-index: 1; + } + + .promo-offer-list { + display: flex; + flex-direction: column; + gap: 16px; + } + + .promo-offer-list.hidden { + display: none; + } + + .promo-offer-list-header { + font-weight: 700; + font-size: 15px; + letter-spacing: 0.02em; + color: var(--text-primary); + } + + .promo-offer-card { + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + background: var(--bg-secondary); + padding: 18px; + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: 12px; + } + + .promo-offer-card-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .promo-offer-card-message { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.55; + word-break: break-word; + } + + .promo-offer-card-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .promo-offer-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + + .promo-offer-tag.muted { + background: rgba(148, 163, 184, 0.16); + color: var(--text-secondary); + } + + .promo-offer-tag.neutral { + background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); + } + + .promo-offer-card-footer { + margin-top: 4px; + display: flex; + justify-content: flex-start; + } + + .promo-offer-button { + position: relative; + border: none; + border-radius: var(--radius); + background: var(--primary); + color: var(--tg-theme-button-text-color); + font-weight: 700; + font-size: 15px; + padding: 10px 18px; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + } + + .promo-offer-button:hover:not(.loading) { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + } + + .promo-offer-button:active:not(.loading) { + transform: translateY(0); + box-shadow: var(--shadow-sm); + } + + .promo-offer-button.loading { + opacity: 0.75; + pointer-events: none; + } + + .promo-offer-button.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.6); + border-top-color: transparent; + animation: spin 0.8s linear infinite; + } + /* Animations */ @keyframes fadeIn { from { @@ -1596,6 +1821,24 @@ + +
@@ -2002,6 +2245,34 @@ 'info.promo_group': 'Promo group', 'info.device_limit': 'Device limit', 'info.autopay': 'Auto-pay', + 'promo.offer.active.title': 'Discount {percent}% is active', + 'promo.offer.active.subtitle': 'Your exclusive offer is already applied to payments.', + 'promo.offer.active.timer': 'Expires in', + 'promo.offer.active.until': 'Valid until {datetime}', + 'promo.offer.active.no_expiry': 'Discount is active', + 'promo.offer.available.header': 'Special offers', + 'promo.offer.default.title': 'Special offer', + 'promo.offer.discount.percent': 'Save {percent}%', + 'promo.offer.expires': 'Valid until {datetime}', + 'promo.offer.effect.test_access': 'Test access', + 'promo.offer.button.claim': 'Activate', + 'promo.offer.claim.title.success': 'Offer activated', + 'promo.offer.claim.title.error': 'Unable to activate offer', + 'promo.offer.claim.success.percent': 'Discount {percent}% is now active.', + 'promo.offer.claim.success.test_access': 'Test access activated. Enjoy the new servers!', + 'promo.offer.claim.success.test_access_until': 'Access valid until {datetime}.', + 'promo.offer.claim.success.test_access_servers': 'New servers: {servers}', + 'promo.offer.claim.success.generic': 'Offer activated successfully.', + 'promo.offer.claim.error.expired': 'This offer has expired.', + 'promo.offer.claim.error.inactive': 'This offer is no longer active.', + 'promo.offer.claim.error.already_claimed': 'You have already activated this offer.', + 'promo.offer.claim.error.no_discount': 'This offer does not provide a discount anymore.', + 'promo.offer.claim.error.subscription_missing': 'Subscription for this account is unavailable.', + 'promo.offer.claim.error.squads_missing': 'Offer configuration is incomplete. Please contact support.', + 'promo.offer.claim.error.already_connected': 'Test servers are already available in your account.', + 'promo.offer.claim.error.remnawave_sync_failed': 'Unable to activate test access right now. Please try again later.', + 'promo.offer.claim.error.unknown': 'Failed to activate the offer. Please try again later.', + 'promo.offer.claim.error.generic': 'Something went wrong. Please try again later.', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -2087,6 +2358,34 @@ 'info.promo_group': 'Уровень', 'info.device_limit': 'Лимит устройств', 'info.autopay': 'Автоплатеж', + 'promo.offer.active.title': 'Скидка {percent}% активна', + 'promo.offer.active.subtitle': 'Ваше эксклюзивное предложение уже применяется к оплатам.', + 'promo.offer.active.timer': 'Истекает через', + 'promo.offer.active.until': 'Действует до {datetime}', + 'promo.offer.active.no_expiry': 'Скидка активна', + 'promo.offer.available.header': 'Спецпредложения', + 'promo.offer.default.title': 'Спецпредложение', + 'promo.offer.discount.percent': 'Скидка {percent}%', + 'promo.offer.expires': 'Действует до {datetime}', + 'promo.offer.effect.test_access': 'Тестовый доступ', + 'promo.offer.button.claim': 'Активировать', + 'promo.offer.claim.title.success': 'Предложение активировано', + 'promo.offer.claim.title.error': 'Не удалось активировать предложение', + 'promo.offer.claim.success.percent': 'Скидка {percent}% активирована.', + 'promo.offer.claim.success.test_access': 'Тестовый доступ подключён. Новые серверы уже доступны.', + 'promo.offer.claim.success.test_access_until': 'Доступ действует до {datetime}.', + 'promo.offer.claim.success.test_access_servers': 'Новые серверы: {servers}', + 'promo.offer.claim.success.generic': 'Предложение успешно активировано.', + 'promo.offer.claim.error.expired': 'Срок действия предложения истёк.', + 'promo.offer.claim.error.inactive': 'Это предложение больше не активно.', + 'promo.offer.claim.error.already_claimed': 'Вы уже активировали это предложение.', + 'promo.offer.claim.error.no_discount': 'По этому предложению больше нет скидки.', + 'promo.offer.claim.error.subscription_missing': 'Подписка для этого аккаунта не найдена.', + 'promo.offer.claim.error.squads_missing': 'Настройки предложения неполные. Свяжитесь с поддержкой.', + 'promo.offer.claim.error.already_connected': 'Тестовые серверы уже подключены к вашему аккаунту.', + 'promo.offer.claim.error.remnawave_sync_failed': 'Не удалось выдать тестовый доступ. Попробуйте позже.', + 'promo.offer.claim.error.unknown': 'Не удалось активировать предложение. Попробуйте позже.', + 'promo.offer.claim.error.generic': 'Что-то пошло не так. Попробуйте позже.', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -2247,6 +2546,8 @@ let currentPlatform = 'android'; let configPurchaseUrl = null; let subscriptionPurchaseUrl = null; + let currentInitData = ''; + let activePromoTimerId = null; let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; @@ -2455,71 +2756,8 @@ 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 subscriptionPayload = await fetchSubscriptionData(initData); + applySubscriptionData(subscriptionPayload); const responseLanguage = resolveLanguage(userData?.user?.language); if (responseLanguage && !languageLockedByUser) { @@ -2575,6 +2813,134 @@ } } + async function fetchSubscriptionData(initDataValue) { + const payload = initDataValue || currentInitData; + if (initDataValue) { + currentInitData = initDataValue; + } + if (!payload) { + throw createError('Authorization Error', 'Missing Telegram ID'); + } + + const response = await fetch('/miniapp/subscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ initData: payload }) + }); + + 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; + } + + const data = await response.json(); + data.subscriptionUrl = data.subscription_url || data.subscriptionUrl || null; + data.subscriptionCryptoLink = data.subscription_crypto_link || data.subscriptionCryptoLink || null; + + const normalizedPurchase = normalizeUrl( + data.subscription_purchase_url + || data.subscriptionPurchaseUrl + ); + if (normalizedPurchase) { + data.subscriptionPurchaseUrl = normalizedPurchase; + } else { + delete data.subscriptionPurchaseUrl; + } + + return data; + } + + function applySubscriptionData(data) { + if (!data) { + return; + } + + userData = data; + userData.subscriptionUrl = userData.subscriptionUrl || userData.subscription_url || null; + userData.subscriptionCryptoLink = userData.subscriptionCryptoLink || userData.subscription_crypto_link || null; + + subscriptionPurchaseUrl = normalizeUrl( + userData.subscription_purchase_url + || userData.subscriptionPurchaseUrl + ); + + if (subscriptionPurchaseUrl) { + userData.subscriptionPurchaseUrl = subscriptionPurchaseUrl; + } else { + delete userData.subscriptionPurchaseUrl; + } + + if (userData.branding) { + applyBrandingOverrides(userData.branding); + } + } + + async function refreshSubscriptionData(options = {}) { + const { silent = false } = options; + try { + const data = await fetchSubscriptionData(); + applySubscriptionData(data); + renderUserData(); + renderApps(); + updateActionButtons(); + } catch (error) { + console.error('Failed to refresh subscription data:', error); + if (!silent) { + const titleKey = 'promo.offer.claim.title.error'; + const messageKey = 'promo.offer.claim.error.generic'; + const title = t(titleKey); + const message = t(messageKey); + showPopup( + message === messageKey + ? 'Unable to update data right now. Please try again later.' + : message, + title === titleKey ? 'Error' : title, + ); + } + } + } + function renderUserData() { if (!userData?.user) { return; @@ -2670,6 +3036,7 @@ : autopayLabel; } + renderPromoOffers(); renderPromoSection(); renderBalanceSection(); renderTransactionHistory(); @@ -3008,6 +3375,545 @@ list.innerHTML = itemsHtml; } + function clearActivePromoTimer() { + if (activePromoTimerId) { + clearInterval(activePromoTimerId); + activePromoTimerId = null; + } + } + + function getPromoTimerLabels() { + const lang = (preferredLanguage || 'en').split('-')[0]; + if (lang === 'ru') { + return { day: 'д', hour: 'ч', minute: 'м' }; + } + return { day: 'd', hour: 'h', minute: 'm' }; + } + + function formatPromoTimeRemaining(expiresDate) { + if (!expiresDate || Number.isNaN(expiresDate.getTime())) { + return null; + } + + const now = new Date(); + const diffMs = expiresDate.getTime() - now.getTime(); + if (diffMs <= 0) { + return null; + } + + const totalMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); + const labels = getPromoTimerLabels(); + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + + 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}`); + + return parts.join(' '); + } + + function renderActivePromoBanner() { + const banner = document.getElementById('activePromoBanner'); + if (!banner) { + return false; + } + + const titleElement = document.getElementById('activePromoTitle'); + const subtitleElement = document.getElementById('activePromoSubtitle'); + const percentElement = document.getElementById('activePromoPercent'); + const timerWrapper = document.getElementById('activePromoTimerWrapper'); + const timerLabelElement = document.getElementById('activePromoTimerLabel'); + const timerValueElement = document.getElementById('activePromoTimer'); + const metaElement = document.getElementById('activePromoMeta'); + + const percentRaw = userData?.user?.promo_offer_discount_percent; + const percent = Number(percentRaw); + const expiresRaw = userData?.user?.promo_offer_discount_expires_at; + const expiresDate = expiresRaw ? new Date(expiresRaw) : null; + + if (!percent || !Number.isFinite(percent) || percent <= 0) { + banner.classList.add('hidden'); + clearActivePromoTimer(); + if (timerWrapper) { + timerWrapper.classList.add('hidden'); + } + if (metaElement) { + metaElement.textContent = ''; + } + return false; + } + + const titleTemplate = t('promo.offer.active.title'); + if (titleElement) { + if (titleTemplate && titleTemplate.includes('{percent}') && titleTemplate !== 'promo.offer.active.title') { + titleElement.textContent = titleTemplate.replace('{percent}', percent); + } else { + titleElement.textContent = `🔥 ${percent}%`; + } + } + + if (subtitleElement) { + const subtitleTemplate = t('promo.offer.active.subtitle'); + if (subtitleTemplate && subtitleTemplate !== 'promo.offer.active.subtitle') { + subtitleElement.textContent = subtitleTemplate; + subtitleElement.classList.remove('hidden'); + } else { + subtitleElement.textContent = ''; + subtitleElement.classList.add('hidden'); + } + } + + if (percentElement) { + percentElement.textContent = `${percent}%`; + } + + if (timerWrapper && timerLabelElement && timerValueElement) { + if (expiresDate && !Number.isNaN(expiresDate.getTime()) && expiresDate > new Date()) { + timerLabelElement.textContent = t('promo.offer.active.timer'); + timerWrapper.classList.remove('hidden'); + + const updateTimer = () => { + const formatted = formatPromoTimeRemaining(expiresDate); + if (!formatted) { + clearActivePromoTimer(); + timerWrapper.classList.add('hidden'); + if (metaElement) { + const fallback = t('promo.offer.claim.error.expired'); + metaElement.textContent = fallback === 'promo.offer.claim.error.expired' + ? 'Expired' + : fallback; + } + return; + } + timerValueElement.textContent = formatted; + }; + + updateTimer(); + clearActivePromoTimer(); + activePromoTimerId = setInterval(updateTimer, 1000); + } else { + timerWrapper.classList.add('hidden'); + clearActivePromoTimer(); + } + } + + if (metaElement) { + if (expiresDate && !Number.isNaN(expiresDate.getTime())) { + const formatted = formatDateTime(expiresDate.toISOString()); + const template = t('promo.offer.active.until'); + metaElement.textContent = template && template.includes('{datetime}') && template !== 'promo.offer.active.until' + ? template.replace('{datetime}', formatted) + : formatted; + } else { + const text = t('promo.offer.active.no_expiry'); + metaElement.textContent = text === 'promo.offer.active.no_expiry' + ? 'Discount is active' + : text; + } + } + + banner.classList.remove('hidden'); + return true; + } + + function renderPendingPromoOffers() { + const container = document.getElementById('pendingPromoList'); + if (!container) { + return false; + } + + container.innerHTML = ''; + const offers = Array.isArray(userData?.promo_offers) ? userData.promo_offers : []; + if (!offers.length) { + container.classList.add('hidden'); + return false; + } + + container.classList.remove('hidden'); + + const header = document.createElement('div'); + header.className = 'promo-offer-list-header'; + const headerTemplate = t('promo.offer.available.header'); + header.textContent = headerTemplate === 'promo.offer.available.header' + ? 'Special offers' + : headerTemplate; + container.appendChild(header); + + offers.forEach(offer => { + const card = document.createElement('div'); + card.className = 'promo-offer-card'; + + const title = document.createElement('div'); + title.className = 'promo-offer-card-title'; + const titleRaw = offer?.name || t('promo.offer.default.title'); + title.textContent = titleRaw === 'promo.offer.default.title' ? 'Special offer' : titleRaw; + card.appendChild(title); + + if (offer?.message) { + const message = document.createElement('div'); + message.className = 'promo-offer-card-message'; + message.innerHTML = escapeHtml(String(offer.message)).replace(/\n/g, '
'); + card.appendChild(message); + } + + const tags = document.createElement('div'); + tags.className = 'promo-offer-card-tags'; + + const discountPercent = Number(offer?.discount_percent || 0); + if (discountPercent > 0) { + const tag = document.createElement('span'); + tag.className = 'promo-offer-tag'; + const template = t('promo.offer.discount.percent'); + tag.textContent = template && template.includes('{percent}') && template !== 'promo.offer.discount.percent' + ? template.replace('{percent}', discountPercent) + : `${discountPercent}%`; + tags.appendChild(tag); + } + + const effectType = String(offer?.effect_type || '').toLowerCase(); + if (effectType === 'test_access') { + const tag = document.createElement('span'); + tag.className = 'promo-offer-tag neutral'; + const label = t('promo.offer.effect.test_access'); + tag.textContent = label === 'promo.offer.effect.test_access' ? 'Test access' : label; + tags.appendChild(tag); + } + + if (offer?.expires_at) { + const formatted = formatDateTime(offer.expires_at); + if (formatted) { + const tag = document.createElement('span'); + tag.className = 'promo-offer-tag muted'; + const template = t('promo.offer.expires'); + tag.textContent = template && template.includes('{datetime}') && template !== 'promo.offer.expires' + ? template.replace('{datetime}', formatted) + : formatted; + tags.appendChild(tag); + } + } + + if (tags.children.length) { + card.appendChild(tags); + } + + const footer = document.createElement('div'); + footer.className = 'promo-offer-card-footer'; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'promo-offer-button'; + const buttonLabel = (offer?.button_text && typeof offer.button_text === 'string') + ? offer.button_text + : t('promo.offer.button.claim'); + button.textContent = buttonLabel === 'promo.offer.button.claim' ? 'Activate' : buttonLabel; + button.addEventListener('click', () => claimPromoOffer(offer.id, button)); + footer.appendChild(button); + card.appendChild(footer); + + container.appendChild(card); + }); + + return true; + } + + function renderPromoOffers() { + const container = document.getElementById('promoOfferContainer'); + if (!container) { + return; + } + + const hasActive = renderActivePromoBanner(); + const hasPending = renderPendingPromoOffers(); + container.classList.toggle('hidden', !(hasActive || hasPending)); + } + + function setPromoOfferButtonLoading(button, isLoading) { + if (!button) { + return; + } + + if (isLoading) { + button.disabled = true; + button.classList.add('loading'); + } else { + button.disabled = false; + button.classList.remove('loading'); + } + } + + function getPromoClaimErrorMessage(errorCode) { + if (!errorCode) { + const fallbackKey = 'promo.offer.claim.error.generic'; + const fallback = t(fallbackKey); + return fallback === fallbackKey + ? 'Something went wrong. Please try again later.' + : fallback; + } + + const normalized = String(errorCode).toLowerCase(); + const key = `promo.offer.claim.error.${normalized}`; + const message = t(key); + if (message && message !== key) { + return message; + } + + const fallbackKey = 'promo.offer.claim.error.generic'; + const fallback = t(fallbackKey); + return fallback === fallbackKey + ? 'Something went wrong. Please try again later.' + : fallback; + } + + function formatPromoClaimSuccessMessage(result) { + const genericKey = 'promo.offer.claim.success.generic'; + const genericMessage = t(genericKey); + + if (!result || typeof result !== 'object') { + return genericMessage === genericKey + ? 'Offer activated successfully.' + : genericMessage; + } + + const effectType = String(result.effect_type || '').toLowerCase(); + + if (effectType === 'percent_discount') { + const percent = Number(result.discount_percent + || result.discountPercent + || userData?.user?.promo_offer_discount_percent + || 0); + const normalized = Number.isFinite(percent) ? Math.max(0, Math.round(percent)) : 0; + const template = t('promo.offer.claim.success.percent'); + if (template && template.includes('{percent}') && template !== 'promo.offer.claim.success.percent') { + return template.replace('{percent}', normalized); + } + if (normalized > 0) { + return `Discount ${normalized}% is now active.`; + } + } + + if (effectType === 'test_access') { + const parts = []; + const baseKey = 'promo.offer.claim.success.test_access'; + const baseMessage = t(baseKey); + parts.push(baseMessage === baseKey + ? 'Test access activated. Enjoy the new servers!' + : baseMessage); + + if (result.test_access_expires_at || result.testAccessExpiresAt) { + const expiresAt = result.test_access_expires_at || result.testAccessExpiresAt; + const formatted = formatDateTime(expiresAt); + if (formatted) { + const untilKey = 'promo.offer.claim.success.test_access_until'; + const untilTemplate = t(untilKey); + const untilMessage = untilTemplate && untilTemplate.includes('{datetime}') && untilTemplate !== untilKey + ? untilTemplate.replace('{datetime}', formatted) + : (untilTemplate === untilKey + ? `Access valid until ${formatted}.` + : untilTemplate); + parts.push(untilMessage); + } + } + + const newSquads = result.newly_added_squads || result.newlyAddedSquads; + if (Array.isArray(newSquads) && newSquads.length) { + const joined = newSquads.join(', '); + const serversKey = 'promo.offer.claim.success.test_access_servers'; + const serversTemplate = t(serversKey); + const serversMessage = serversTemplate && serversTemplate.includes('{servers}') && serversTemplate !== serversKey + ? serversTemplate.replace('{servers}', joined) + : (serversTemplate === serversKey + ? `New servers: ${joined}` + : serversTemplate); + parts.push(serversMessage); + } + + return parts.join(' '); + } + + return genericMessage === genericKey + ? 'Offer activated successfully.' + : genericMessage; + } + + function applyPromoClaimResult(offerId, result) { + if (!userData || typeof userData !== 'object') { + return; + } + + if (Array.isArray(userData.promo_offers)) { + userData.promo_offers = userData.promo_offers.filter(offer => offer?.id !== offerId); + } + + if (userData.user && typeof userData.user === 'object') { + if (Object.prototype.hasOwnProperty.call(result || {}, 'discount_percent') + || Object.prototype.hasOwnProperty.call(result || {}, 'discountPercent')) { + const percentRaw = result?.discount_percent ?? result?.discountPercent; + const percent = Number(percentRaw); + if (Number.isFinite(percent)) { + userData.user.promo_offer_discount_percent = percent; + } + } + + if (Object.prototype.hasOwnProperty.call(result || {}, 'discount_expires_at') + || Object.prototype.hasOwnProperty.call(result || {}, 'discountExpiresAt')) { + const expiresAt = result?.discount_expires_at ?? result?.discountExpiresAt; + userData.user.promo_offer_discount_expires_at = expiresAt || null; + } else if ((result?.effect_type || result?.effectType) === 'percent_discount' + && (result?.discount_percent || result?.discountPercent)) { + userData.user.promo_offer_discount_expires_at = null; + } + } + + if (Array.isArray(userData.connected_squads)) { + const newSquads = result?.newly_added_squads || result?.newlyAddedSquads; + if (Array.isArray(newSquads) && newSquads.length) { + const merged = new Set(userData.connected_squads); + newSquads.forEach(squad => { + if (squad) { + merged.add(String(squad)); + } + }); + userData.connected_squads = Array.from(merged); + } + } + + if (Array.isArray(userData.connected_servers)) { + const newSquads = result?.newly_added_squads || result?.newlyAddedSquads; + if (Array.isArray(newSquads) && newSquads.length) { + const knownIds = new Set( + userData.connected_servers + .map(server => (server && server.uuid ? String(server.uuid) : null)) + .filter(Boolean) + ); + newSquads.forEach(squad => { + const normalized = squad ? String(squad) : ''; + if (normalized && !knownIds.has(normalized)) { + userData.connected_servers.push({ + uuid: normalized, + name: normalized, + }); + knownIds.add(normalized); + } + }); + } + } + } + + async function claimPromoOffer(offerId, triggerButton) { + const button = triggerButton instanceof HTMLElement ? triggerButton : null; + + if (!offerId) { + return; + } + + const initData = currentInitData; + if (!initData) { + const titleKey = 'promo.offer.claim.title.error'; + const title = t(titleKey); + const messageKey = 'promo.offer.claim.error.generic'; + const message = t(messageKey); + showPopup( + message === messageKey + ? 'Unable to activate the offer. Please reopen the mini app from Telegram.' + : message, + title === titleKey ? 'Error' : title, + ); + return; + } + + setPromoOfferButtonLoading(button, true); + + try { + const response = await fetch(`/miniapp/promo-offers/${encodeURIComponent(offerId)}/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ initData }) + }); + + let payload = null; + const contentType = response.headers?.get?.('content-type') || ''; + if (contentType.includes('application/json')) { + try { + payload = await response.json(); + } catch (parseError) { + console.warn('Unable to parse promo offer claim response:', parseError); + } + } + + if (!response.ok) { + const titleKey = 'promo.offer.claim.title.error'; + const title = t(titleKey); + const fallbackKey = 'promo.offer.claim.error.generic'; + const fallback = t(fallbackKey); + const detail = payload && typeof payload === 'object' + ? (payload.detail || payload.message || payload.error) + : null; + const message = typeof detail === 'string' && detail.trim().length + ? detail.trim() + : (fallback === fallbackKey + ? 'Unable to activate the offer. Please try again later.' + : fallback); + showPopup(message, title === titleKey ? 'Error' : title); + return; + } + + if (!payload || typeof payload !== 'object') { + const titleKey = 'promo.offer.claim.title.error'; + const title = t(titleKey); + const fallbackKey = 'promo.offer.claim.error.generic'; + const fallback = t(fallbackKey); + const message = fallback === fallbackKey + ? 'Something went wrong. Please try again later.' + : fallback; + showPopup(message, title === titleKey ? 'Error' : title); + return; + } + + if (!payload.success) { + const titleKey = 'promo.offer.claim.title.error'; + const title = t(titleKey); + const message = getPromoClaimErrorMessage(payload.error_code || payload.errorCode); + showPopup(message, title === titleKey ? 'Error' : title); + return; + } + + applyPromoClaimResult(offerId, payload); + renderPromoOffers(); + renderServersList(); + + const titleKey = 'promo.offer.claim.title.success'; + const title = t(titleKey); + const message = formatPromoClaimSuccessMessage(payload); + showPopup(message, title === titleKey ? 'Success' : title); + + try { + await refreshSubscriptionData({ silent: true }); + } catch (refreshError) { + console.warn('Failed to refresh subscription data after promo claim:', refreshError); + } + } catch (error) { + console.error('Failed to claim promo offer:', error); + const titleKey = 'promo.offer.claim.title.error'; + const title = t(titleKey); + const fallbackKey = 'promo.offer.claim.error.generic'; + const fallback = t(fallbackKey); + const message = fallback === fallbackKey + ? 'Something went wrong. Please try again later.' + : fallback; + showPopup(message, title === titleKey ? 'Error' : title); + } finally { + setPromoOfferButtonLoading(button, false); + } + } + function renderServersList() { const list = document.getElementById('serversList'); const emptyState = document.getElementById('serversEmpty');