From 6d01d32dc2eec574dfdc0decbd5ef983f0845393 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 9 Oct 2025 04:42:58 +0300 Subject: [PATCH] feat: show promo offers in miniapp --- app/webapi/routes/miniapp.py | 321 +++++++++++++-- app/webapi/schemas/miniapp.py | 35 ++ miniapp/index.html | 742 ++++++++++++++++++++++++++++++---- 3 files changed, 975 insertions(+), 123 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 09a130d1..7bb6f6eb 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,6 +1,7 @@ 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 @@ -8,8 +9,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, + 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 @@ -17,6 +24,7 @@ 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 ( @@ -29,6 +37,9 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, MiniAppAutoPromoGroupLevel, + MiniAppPromoOffer, + MiniAppPromoOfferClaimRequest, + MiniAppPromoOfferClaimResponse, MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, @@ -142,6 +153,53 @@ 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: @@ -266,44 +324,15 @@ 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 - - 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, - ) + user = await _resolve_authorized_user( + db, + payload.init_data, + require_subscription=True, + include_purchase_url=True, + ) 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)) @@ -387,8 +416,102 @@ 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, @@ -426,6 +549,132 @@ 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: diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 360e6fa5..26efaf83 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -34,6 +34,26 @@ 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): @@ -117,4 +137,19 @@ 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) diff --git a/miniapp/index.html b/miniapp/index.html index 7161ee85..5ce58abc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -397,6 +397,168 @@ 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)); @@ -1537,6 +1699,26 @@ 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, @@ -1596,6 +1778,8 @@ + +
@@ -2021,6 +2205,27 @@ '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', @@ -2106,6 +2311,27 @@ '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': 'Рекомендуем', @@ -2250,6 +2476,9 @@ let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; + let promoTimerIntervalId = null; + let promoClaimInFlight = false; + let reloadInFlight = false; function resolveLanguage(lang) { if (!lang) { @@ -2318,6 +2547,95 @@ 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, @@ -2436,6 +2754,128 @@ 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; @@ -2449,96 +2889,12 @@ } await loadAppsConfig(); - - 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(); + const data = await fetchSubscriptionData(); document.getElementById('loadingState').classList.add('hidden'); document.getElementById('mainContent').classList.remove('hidden'); - - // Add animation to cards - document.querySelectorAll('.card').forEach((card, index) => { - setTimeout(() => { - card.classList.add('animate-in'); - }, index * 100); - }); + + applyUserDataPayload(data, { animateCards: true }); } catch (error) { console.error('Initialization error:', error); showError(error); @@ -2575,6 +2931,205 @@ } } + 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 = ` +
+
+
+
${escapeHtml(t('promo.offer.active.title'))}
+
${escapeHtml(t('promo.offer.active.subtitle'))}
+
+
+
+
${escapeHtml(discountText)}
+
+ ${escapeHtml(t('promo.offer.active.timer'))} + ${escapeHtml(timerValue)} +
+
+ `; + 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 + ? `
${badges.map(badge => `${escapeHtml(badge)}`).join('')}
` + : ''; + + 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 = ` +
+
${escapeHtml(typeConfig.icon)}
+
+
${escapeHtml(typeConfig.title)}
+
${escapeHtml(t('promo.offer.pending.subtitle'))}
+
+
+ ${badgeHtml} + + `; + 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; @@ -2670,6 +3225,7 @@ : autopayLabel; } + renderPromoOffers(); renderPromoSection(); renderBalanceSection(); renderTransactionHistory(); @@ -3734,6 +4290,18 @@ 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;