From 5bd618a7994cdd8a3db07d4f2fb07f1d6f2c1903 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 9 Oct 2025 04:50:27 +0300 Subject: [PATCH] Revert "feat: surface promo offers in mini app" --- app/webapi/routes/miniapp.py | 321 ++------------- app/webapi/schemas/miniapp.py | 35 -- miniapp/index.html | 742 ++++------------------------------ 3 files changed, 123 insertions(+), 975 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 7bb6f6eb..09a130d1 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException, status @@ -9,14 +8,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.discount_offer import ( - get_offer_by_id, - list_discount_offers, - mark_offer_claimed, -) from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.promo_group import get_auto_assign_promo_groups -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import get_user_by_telegram_id from app.database.models import PromoGroup, Subscription, Transaction, User @@ -24,7 +17,6 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) -from app.services.promo_offer_service import promo_offer_service from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( @@ -37,9 +29,6 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, MiniAppAutoPromoGroupLevel, - MiniAppPromoOffer, - MiniAppPromoOfferClaimRequest, - MiniAppPromoOfferClaimResponse, MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, @@ -153,53 +142,6 @@ async def _resolve_connected_servers( return connected_servers -async def _resolve_authorized_user( - db: AsyncSession, - init_data: str, - *, - require_subscription: bool = False, - include_purchase_url: bool = False, -) -> User: - try: - webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(error), - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user identifier", - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user or (require_subscription and not getattr(user, "subscription", None)): - detail: Union[str, Dict[str, str]] = ( - "Subscription not found" if require_subscription else "User not found" - ) - if include_purchase_url and require_subscription: - purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() - if purchase_url: - detail = { - "message": "Subscription not found", - "purchase_url": purchase_url, - } - raise HTTPException(status.HTTP_404_NOT_FOUND, detail=detail) - - return user - - async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: remnawave_uuid = getattr(user, "remnawave_uuid", None) if not remnawave_uuid: @@ -324,15 +266,44 @@ async def get_subscription_details( payload: MiniAppSubscriptionRequest, db: AsyncSession = Depends(get_db_session), ) -> MiniAppSubscriptionResponse: - user = await _resolve_authorized_user( - db, - payload.init_data, - require_subscription=True, - include_purchase_url=True, - ) + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(error), + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() + if not user or not user.subscription: + detail: Union[str, Dict[str, str]] = "Subscription not found" + if purchase_url: + detail = { + "message": "Subscription not found", + "purchase_url": purchase_url, + } + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) subscription = user.subscription - purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() traffic_used = _format_gb(subscription.traffic_used_gb) traffic_limit = subscription.traffic_limit_gb or 0 lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) @@ -416,102 +387,8 @@ async def get_subscription_details( traffic_limit_label=_format_limit_label(traffic_limit), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, - promo_offer_discount_percent=int( - getattr(user, "promo_offer_discount_percent", 0) or 0 - ), - promo_offer_discount_source=getattr(user, "promo_offer_discount_source", None), - promo_offer_discount_expires_at=getattr( - user, - "promo_offer_discount_expires_at", - None, - ), ) - raw_offers = await list_discount_offers(db, user_id=user.id, is_active=True) - now = datetime.utcnow() - template_cache: Dict[int, Optional[Any]] = {} - promo_offers: List[MiniAppPromoOffer] = [] - - for offer in raw_offers: - expires_at = getattr(offer, "expires_at", None) - if not expires_at or expires_at <= now: - continue - if getattr(offer, "claimed_at", None): - continue - - extra_data_raw = getattr(offer, "extra_data", {}) - normalized_extra: Dict[str, Any] - if isinstance(extra_data_raw, dict): - normalized_extra = dict(extra_data_raw) - else: - normalized_extra = {} - - offer_type = normalized_extra.get("offer_type") - button_text = normalized_extra.get("button_text") - message_text = normalized_extra.get("message_text") - - template_id: Optional[int] = None - raw_template_id = normalized_extra.get("template_id") - if raw_template_id is not None: - try: - template_id = int(raw_template_id) - except (TypeError, ValueError): - template_id = None - - template = None - if template_id is not None: - if template_id not in template_cache: - template_cache[template_id] = await get_promo_offer_template_by_id( - db, - template_id, - ) - template = template_cache.get(template_id) - - active_hours_raw = normalized_extra.get("active_discount_hours") - if template: - offer_type = offer_type or template.offer_type - button_text = button_text or template.button_text - message_text = message_text or template.message_text - if (not active_hours_raw) and template.active_discount_hours: - active_hours_raw = template.active_discount_hours - - try: - active_hours = int(float(active_hours_raw)) if active_hours_raw is not None else None - except (TypeError, ValueError): - active_hours = None - - if template_id is not None: - normalized_extra.setdefault("template_id", template_id) - if offer_type: - normalized_extra["offer_type"] = offer_type - if button_text: - normalized_extra["button_text"] = button_text - if message_text: - normalized_extra["message_text"] = message_text - if active_hours is not None: - normalized_extra["active_discount_hours"] = active_hours - - promo_offers.append( - MiniAppPromoOffer( - id=offer.id, - notification_type=str(getattr(offer, "notification_type", "")), - discount_percent=int(getattr(offer, "discount_percent", 0) or 0), - bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), - expires_at=expires_at, - created_at=getattr(offer, "created_at", now), - updated_at=getattr(offer, "updated_at", now), - effect_type=getattr(offer, "effect_type", "percent_discount") or "percent_discount", - is_active=bool(getattr(offer, "is_active", True)), - offer_type=offer_type, - button_text=button_text, - message_text=message_text, - active_discount_hours=active_hours, - extra_data=normalized_extra, - ) - ) - - promo_offers.sort(key=lambda item: item.expires_at) - return MiniAppSubscriptionResponse( subscription_id=subscription.id, remnawave_short_uuid=subscription.remnawave_short_uuid, @@ -549,132 +426,6 @@ async def get_subscription_details( subscription_type="trial" if subscription.is_trial else "paid", autopay_enabled=bool(subscription.autopay_enabled), branding=settings.get_miniapp_branding(), - promo_offers=promo_offers, - ) - - -@router.post( - "/promo-offers/{offer_id}/claim", - response_model=MiniAppPromoOfferClaimResponse, -) -async def claim_miniapp_promo_offer( - offer_id: int, - payload: MiniAppPromoOfferClaimRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppPromoOfferClaimResponse: - user = await _resolve_authorized_user(db, payload.init_data) - - offer = await get_offer_by_id(db, offer_id) - if not offer or offer.user_id != user.id: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Offer not found") - - now = datetime.utcnow() - if getattr(offer, "claimed_at", None) is not None: - return MiniAppPromoOfferClaimResponse( - success=False, - status="already_claimed", - message="Offer already claimed", - ) - - expires_at = getattr(offer, "expires_at", None) - if not offer.is_active or (expires_at and expires_at <= now): - offer.is_active = False - await db.commit() - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Offer expired") - - effect_type = (getattr(offer, "effect_type", "percent_discount") or "percent_discount").lower() - extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {} - - if effect_type == "test_access": - success, newly_added, access_expires_at, error_code = await promo_offer_service.grant_test_access( - db, - user, - offer, - ) - if not success: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": error_code or "test_access_failed", - "message": "Unable to grant test access", - }, - ) - - await mark_offer_claimed( - db, - offer, - details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": access_expires_at.isoformat() if access_expires_at else None, - }, - ) - - return MiniAppPromoOfferClaimResponse( - success=True, - status="test_access_granted", - message="Test access activated", - test_access_expires_at=access_expires_at, - newly_added_squads=newly_added or [], - ) - - if effect_type == "balance_bonus": - effect_type = "percent_discount" - - discount_percent = int(getattr(offer, "discount_percent", 0) or 0) - if discount_percent <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Discount percent is not available", - ) - - user.promo_offer_discount_percent = discount_percent - user.promo_offer_discount_source = getattr(offer, "notification_type", None) - user.updated_at = now - - raw_duration = extra_data.get("active_discount_hours") if isinstance(extra_data, dict) else None - template_id = None - if isinstance(extra_data, dict): - template_id = extra_data.get("template_id") - - if (raw_duration in (None, "", 0, "0")) and template_id is not None: - try: - template = await get_promo_offer_template_by_id(db, int(template_id)) - except (TypeError, ValueError): - template = None - if template and template.active_discount_hours: - raw_duration = template.active_discount_hours - - try: - duration_hours = int(float(raw_duration)) if raw_duration is not None else None - except (TypeError, ValueError): - duration_hours = None - - discount_expires_at = None - if duration_hours and duration_hours > 0: - discount_expires_at = now + timedelta(hours=duration_hours) - - user.promo_offer_discount_expires_at = discount_expires_at - - await mark_offer_claimed( - db, - offer, - details={ - "context": "discount_claim", - "discount_percent": discount_percent, - "discount_expires_at": discount_expires_at.isoformat() - if discount_expires_at - else None, - }, - ) - await db.refresh(user) - - return MiniAppPromoOfferClaimResponse( - success=True, - status="discount_claimed", - message="Discount activated", - discount_percent=discount_percent, - discount_expires_at=discount_expires_at, ) def _safe_int(value: Any) -> int: diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 26efaf83..360e6fa5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -34,26 +34,6 @@ class MiniAppSubscriptionUser(BaseModel): traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False - promo_offer_discount_percent: int = 0 - promo_offer_discount_source: Optional[str] = None - promo_offer_discount_expires_at: Optional[datetime] = None - - -class MiniAppPromoOffer(BaseModel): - id: int - notification_type: str - discount_percent: int = 0 - bonus_amount_kopeks: int = 0 - expires_at: datetime - created_at: datetime - updated_at: datetime - effect_type: str - is_active: bool = True - offer_type: Optional[str] = None - button_text: Optional[str] = None - message_text: Optional[str] = None - active_discount_hours: Optional[int] = None - extra_data: Dict[str, Any] = Field(default_factory=dict) class MiniAppPromoGroup(BaseModel): @@ -137,19 +117,4 @@ class MiniAppSubscriptionResponse(BaseModel): subscription_type: str autopay_enabled: bool = False branding: Optional[MiniAppBranding] = None - promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list) - - -class MiniAppPromoOfferClaimRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppPromoOfferClaimResponse(BaseModel): - success: bool = True - status: str - message: Optional[str] = None - discount_percent: Optional[int] = None - discount_expires_at: Optional[datetime] = None - test_access_expires_at: Optional[datetime] = None - newly_added_squads: List[str] = Field(default_factory=list) diff --git a/miniapp/index.html b/miniapp/index.html index 5ce58abc..7161ee85 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -397,168 +397,6 @@ padding: 0 16px 16px; } - /* Promo Offer Banner */ - .promo-offer-container { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 20px; - } - - .promo-offer-card { - position: relative; - padding: 18px 20px; - border-radius: var(--radius-lg); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: 16px; - transition: transform 0.3s ease, box-shadow 0.3s ease; - } - - .promo-offer-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - } - - .promo-offer-card-active { - background: linear-gradient(140deg, rgba(var(--primary-rgb), 0.18), rgba(var(--primary-rgb), 0.42)); - color: var(--text-primary); - border-color: rgba(var(--primary-rgb), 0.4); - box-shadow: var(--shadow-md); - } - - .promo-offer-card-header { - display: flex; - align-items: center; - gap: 14px; - } - - .promo-offer-icon { - width: 42px; - height: 42px; - border-radius: 14px; - background: rgba(var(--primary-rgb), 0.12); - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; - } - - .promo-offer-card-active .promo-offer-icon { - background: rgba(255, 255, 255, 0.2); - color: #fff; - } - - .promo-offer-header-text { - display: flex; - flex-direction: column; - gap: 4px; - } - - .promo-offer-title { - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - } - - .promo-offer-subtitle { - font-size: 14px; - color: var(--text-secondary); - } - - .promo-offer-card-active .promo-offer-subtitle { - color: rgba(255, 255, 255, 0.85); - } - - .promo-offer-card-body { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; - } - - .promo-offer-highlight { - font-size: 32px; - font-weight: 800; - letter-spacing: 0.5px; - color: var(--primary); - } - - .promo-offer-card-active .promo-offer-highlight { - color: var(--tg-theme-button-text-color); - background: rgba(255, 255, 255, 0.16); - padding: 6px 14px; - border-radius: var(--radius); - } - - .promo-offer-badges { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .promo-offer-badge { - display: inline-flex; - align-items: center; - padding: 6px 12px; - border-radius: var(--radius); - background: rgba(var(--primary-rgb), 0.12); - color: var(--primary); - font-weight: 600; - font-size: 13px; - } - - .promo-offer-card-active .promo-offer-badge { - background: rgba(255, 255, 255, 0.18); - color: #fff; - } - - .promo-offer-card-footer { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 16px; - } - - .promo-offer-timer { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 13px; - } - - .promo-offer-timer-label { - text-transform: uppercase; - letter-spacing: 0.4px; - color: var(--text-secondary); - font-weight: 600; - } - - .promo-offer-timer-value { - font-size: 16px; - font-weight: 700; - color: var(--text-primary); - } - - .promo-offer-card-active .promo-offer-timer-label, - .promo-offer-card-active .promo-offer-timer-value { - color: rgba(255, 255, 255, 0.9); - } - - .promo-offer-action { - min-width: 160px; - white-space: nowrap; - } - - .promo-offer-action:disabled { - opacity: 0.7; - } - /* User Card Specific */ .user-card { background: linear-gradient(135deg, var(--bg-secondary), rgba(var(--primary-rgb), 0.05)); @@ -1699,26 +1537,6 @@ color: var(--primary); } - :root[data-theme="dark"] .promo-offer-card { - background: rgba(30, 41, 59, 0.78); - border-color: rgba(148, 163, 184, 0.28); - box-shadow: var(--shadow-md); - } - - :root[data-theme="dark"] .promo-offer-card-active { - background: linear-gradient(145deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.55)); - border-color: rgba(var(--primary-rgb), 0.45); - } - - :root[data-theme="dark"] .promo-offer-card-active .promo-offer-highlight { - background: rgba(15, 23, 42, 0.35); - } - - :root[data-theme="dark"] .promo-offer-badge { - background: rgba(var(--primary-rgb), 0.22); - color: #fff; - } - :root[data-theme="dark"] body, :root[data-theme="dark"] .card, :root[data-theme="dark"] .user-card, @@ -1778,8 +1596,6 @@ - -
@@ -2205,27 +2021,6 @@ 'promo.periods.title_short': 'Periods', 'promo.periods.label': '{months} mo', 'promo.periods.month_suffix': 'mo', - 'promo.offer.active.title': 'Active personal discount', - 'promo.offer.active.subtitle': 'Applied automatically during payment', - 'promo.offer.active.timer': 'Valid for', - 'promo.offer.pending.subtitle': 'Activate to secure your bonus.', - 'promo.offer.timer.pending': 'Expires in', - 'promo.offer.timer.expired': 'Expired', - 'promo.offer.button.accept': 'Activate', - 'promo.offer.button.loading': 'Activating…', - 'promo.offer.button.expired': 'Expired', - 'promo.offer.type.generic': 'Special offer', - 'promo.offer.type.extend': 'Renewal discount', - 'promo.offer.type.purchase': 'Welcome back discount', - 'promo.offer.type.test_access': 'Test access', - 'promo.offer.badge.discount': '−{value}', - 'promo.offer.badge.active_hours': '{hours} after activation', - 'promo.offer.badge.test_access': 'Test servers', - 'promo.offer.claim.success.title': 'Success', - 'promo.offer.claim.success.discount': 'Discount activated! It will be applied automatically at checkout.', - 'promo.offer.claim.success.test_access': 'Test access activated! New servers are now available.', - 'promo.offer.claim.error.title': 'Error', - 'promo.offer.claim.error': 'Failed to activate the offer. Please try again later.', 'apps.title': 'Installation guide', 'apps.no_data': 'No installation guide available for this platform yet.', 'apps.featured': 'Recommended', @@ -2311,27 +2106,6 @@ 'promo.periods.title_short': 'Периоды', 'promo.periods.label': '{months} мес.', 'promo.periods.month_suffix': 'мес.', - 'promo.offer.active.title': 'Активная скидка', - 'promo.offer.active.subtitle': 'Применится автоматически при оплате', - 'promo.offer.active.timer': 'Действует ещё', - 'promo.offer.pending.subtitle': 'Активируйте, чтобы закрепить бонус.', - 'promo.offer.timer.pending': 'Истекает через', - 'promo.offer.timer.expired': 'Истекло', - 'promo.offer.button.accept': 'Принять', - 'promo.offer.button.loading': 'Активация…', - 'promo.offer.button.expired': 'Истекло', - 'promo.offer.type.generic': 'Специальное предложение', - 'promo.offer.type.extend': 'Скидка на продление', - 'promo.offer.type.purchase': 'Скидка на возвращение', - 'promo.offer.type.test_access': 'Тестовый доступ', - 'promo.offer.badge.discount': '−{value}', - 'promo.offer.badge.active_hours': '{hours} после активации', - 'promo.offer.badge.test_access': 'Тестовые сервера', - 'promo.offer.claim.success.title': 'Готово', - 'promo.offer.claim.success.discount': 'Скидка активирована! Она автоматически применится при оплате.', - 'promo.offer.claim.success.test_access': 'Тестовый доступ активирован! Проверьте список серверов.', - 'promo.offer.claim.error.title': 'Ошибка', - 'promo.offer.claim.error': 'Не удалось активировать предложение. Попробуйте позже.', 'apps.title': 'Инструкция по установке', 'apps.no_data': 'Для этой платформы инструкция пока недоступна.', 'apps.featured': 'Рекомендуем', @@ -2476,9 +2250,6 @@ let preferredLanguage = 'en'; let languageLockedByUser = false; let currentErrorState = null; - let promoTimerIntervalId = null; - let promoClaimInFlight = false; - let reloadInFlight = false; function resolveLanguage(lang) { if (!lang) { @@ -2547,95 +2318,6 @@ return trimmed.length ? trimmed : null; } - function formatString(template, values = {}) { - if (typeof template !== 'string') { - return template ?? ''; - } - return template.replace(/\{(\w+)\}/g, (_, key) => { - if (Object.prototype.hasOwnProperty.call(values, key)) { - const rawValue = values[key]; - return rawValue === undefined || rawValue === null ? '' : String(rawValue); - } - return `{${key}}`; - }); - } - - function parseDate(value) { - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; - } - - function formatPromoDuration(milliseconds) { - if (!Number.isFinite(milliseconds) || milliseconds <= 0) { - return t('promo.offer.timer.expired'); - } - const totalSeconds = Math.floor(milliseconds / 1000); - const days = Math.floor(totalSeconds / 86400); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - const lang = (preferredLanguage || 'en').toLowerCase(); - const labels = lang.startsWith('ru') - ? { day: 'д', hour: 'ч', minute: 'м', second: 'с' } - : { day: 'd', hour: 'h', minute: 'm', second: 's' }; - const parts = []; - if (days > 0) { - parts.push(`${days}${labels.day}`); - } - if (hours > 0 || days > 0) { - parts.push(`${hours}${labels.hour}`); - } - parts.push(`${minutes}${labels.minute}`); - if (days === 0 && hours === 0) { - parts.push(`${seconds}${labels.second}`); - } - return parts.slice(0, 3).join(' '); - } - - function stopPromoTimerUpdates() { - if (promoTimerIntervalId) { - clearInterval(promoTimerIntervalId); - promoTimerIntervalId = null; - } - } - - function updatePromoCountdowns() { - const now = Date.now(); - document.querySelectorAll('[data-promo-countdown]').forEach(element => { - const target = element.getAttribute('data-promo-countdown'); - if (!target) { - return; - } - const expiresAt = Date.parse(target); - if (Number.isNaN(expiresAt)) { - element.textContent = t('promo.offer.timer.expired'); - element.removeAttribute('data-promo-countdown'); - return; - } - const remaining = expiresAt - now; - if (remaining <= 0) { - element.textContent = t('promo.offer.timer.expired'); - const actionButton = element.closest('.promo-offer-card')?.querySelector('[data-action="claim-promo-offer"]'); - if (actionButton) { - actionButton.disabled = true; - actionButton.textContent = t('promo.offer.button.expired'); - } - element.removeAttribute('data-promo-countdown'); - return; - } - element.textContent = formatPromoDuration(remaining); - }); - } - - function startPromoTimerUpdates() { - stopPromoTimerUpdates(); - updatePromoCountdowns(); - promoTimerIntervalId = window.setInterval(updatePromoCountdowns, 1000); - } - function getEffectivePurchaseUrl() { const candidates = [ currentErrorState?.purchaseUrl, @@ -2754,128 +2436,6 @@ return error; } - async function fetchSubscriptionData() { - const initData = tg.initData || ''; - if (!initData) { - throw createError('Authorization Error', 'Missing Telegram ID'); - } - - const response = await fetch('/miniapp/subscription', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ initData }) - }); - - if (!response.ok) { - let detail = response.status === 401 - ? 'Authorization failed. Please open the mini app from Telegram.' - : 'Subscription not found'; - let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; - let purchaseUrl = null; - - try { - const errorPayload = await response.json(); - if (errorPayload?.detail) { - if (typeof errorPayload.detail === 'string') { - detail = errorPayload.detail; - } else if (typeof errorPayload.detail === 'object') { - if (typeof errorPayload.detail.message === 'string') { - detail = errorPayload.detail.message; - } - purchaseUrl = errorPayload.detail.purchase_url - || errorPayload.detail.purchaseUrl - || purchaseUrl; - } - } else if (typeof errorPayload?.message === 'string') { - detail = errorPayload.message; - } - - if (typeof errorPayload?.title === 'string') { - title = errorPayload.title; - } - - purchaseUrl = purchaseUrl - || errorPayload?.purchase_url - || errorPayload?.purchaseUrl - || null; - } catch (parseError) { - // ignore - } - - const errorObject = createError(title, detail, response.status); - const normalizedPurchaseUrl = normalizeUrl(purchaseUrl); - if (normalizedPurchaseUrl) { - errorObject.purchaseUrl = normalizedPurchaseUrl; - } - throw errorObject; - } - - return response.json(); - } - - function applyUserDataPayload(data, options = {}) { - if (!data) { - return; - } - - userData = data; - userData.subscriptionUrl = userData.subscription_url || null; - userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; - subscriptionPurchaseUrl = normalizeUrl( - userData.subscription_purchase_url - || userData.subscriptionPurchaseUrl - ); - if (subscriptionPurchaseUrl) { - userData.subscriptionPurchaseUrl = subscriptionPurchaseUrl; - } - if (userData.branding) { - applyBrandingOverrides(userData.branding); - } - - currentErrorState = null; - document.getElementById('errorState')?.classList.add('hidden'); - - const responseLanguage = resolveLanguage(userData?.user?.language); - if (responseLanguage && !languageLockedByUser) { - preferredLanguage = responseLanguage; - } - - detectPlatform(); - setActivePlatformButton(); - refreshAfterLanguageChange(); - - if (options.animateCards) { - document.querySelectorAll('.card').forEach((card, index) => { - setTimeout(() => { - card.classList.add('animate-in'); - }, index * 100); - }); - } - } - - async function reloadSubscriptionData(options = {}) { - if (reloadInFlight) { - return; - } - reloadInFlight = true; - try { - const data = await fetchSubscriptionData(); - applyUserDataPayload(data, { animateCards: Boolean(options.animateCards) }); - } catch (error) { - console.error('Failed to reload subscription data:', error); - if (options.showError !== false) { - showPopup( - error?.message || t('promo.offer.claim.error') || 'Unable to refresh data.', - t('promo.offer.claim.error.title') || 'Error' - ); - } - } finally { - reloadInFlight = false; - } - } - async function init() { try { const telegramUser = tg.initDataUnsafe?.user; @@ -2889,12 +2449,96 @@ } await loadAppsConfig(); - const data = await fetchSubscriptionData(); + + const initData = tg.initData || ''; + if (!initData) { + throw createError('Authorization Error', 'Missing Telegram ID'); + } + + const response = await fetch('/miniapp/subscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ initData }) + }); + + if (!response.ok) { + let detail = response.status === 401 + ? 'Authorization failed. Please open the mini app from Telegram.' + : 'Subscription not found'; + let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found'; + let purchaseUrl = null; + + try { + const errorPayload = await response.json(); + if (errorPayload?.detail) { + if (typeof errorPayload.detail === 'string') { + detail = errorPayload.detail; + } else if (typeof errorPayload.detail === 'object') { + if (typeof errorPayload.detail.message === 'string') { + detail = errorPayload.detail.message; + } + purchaseUrl = errorPayload.detail.purchase_url + || errorPayload.detail.purchaseUrl + || purchaseUrl; + } + } else if (typeof errorPayload?.message === 'string') { + detail = errorPayload.message; + } + + if (typeof errorPayload?.title === 'string') { + title = errorPayload.title; + } + + purchaseUrl = purchaseUrl + || errorPayload?.purchase_url + || errorPayload?.purchaseUrl + || null; + } catch (parseError) { + // ignore + } + + const errorObject = createError(title, detail, response.status); + const normalizedPurchaseUrl = normalizeUrl(purchaseUrl); + if (normalizedPurchaseUrl) { + errorObject.purchaseUrl = normalizedPurchaseUrl; + } + throw errorObject; + } + + userData = await response.json(); + userData.subscriptionUrl = userData.subscription_url || null; + userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + subscriptionPurchaseUrl = normalizeUrl( + userData.subscription_purchase_url + || userData.subscriptionPurchaseUrl + ); + if (subscriptionPurchaseUrl) { + userData.subscriptionPurchaseUrl = subscriptionPurchaseUrl; + } + if (userData.branding) { + applyBrandingOverrides(userData.branding); + } + + const responseLanguage = resolveLanguage(userData?.user?.language); + if (responseLanguage && !languageLockedByUser) { + preferredLanguage = responseLanguage; + } + + detectPlatform(); + setActivePlatformButton(); + refreshAfterLanguageChange(); document.getElementById('loadingState').classList.add('hidden'); document.getElementById('mainContent').classList.remove('hidden'); - - applyUserDataPayload(data, { animateCards: true }); + + // Add animation to cards + document.querySelectorAll('.card').forEach((card, index) => { + setTimeout(() => { + card.classList.add('animate-in'); + }, index * 100); + }); } catch (error) { console.error('Initialization error:', error); showError(error); @@ -2931,205 +2575,6 @@ } } - function getPromoOfferTypeConfig(offer) { - const rawType = String(offer?.offer_type || offer?.effect_type || '').toLowerCase(); - const mapping = { - test_access: { icon: '🧪', title: t('promo.offer.type.test_access'), id: 'test_access' }, - extend_discount: { icon: '💎', title: t('promo.offer.type.extend'), id: 'extend_discount' }, - purchase_discount: { icon: '🎯', title: t('promo.offer.type.purchase'), id: 'purchase_discount' } - }; - return mapping[rawType] || { icon: '🎁', title: t('promo.offer.type.generic'), id: 'generic' }; - } - - function createActivePromoCard({ percent, expiresAt }) { - const card = document.createElement('div'); - card.className = 'promo-offer-card promo-offer-card-active'; - const discountText = formatString(t('promo.offer.badge.discount'), { value: `${percent}%` }); - const timerValue = formatPromoDuration(expiresAt.getTime() - Date.now()); - card.innerHTML = ` -
-
-
-
${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; @@ -3225,7 +2670,6 @@ : autopayLabel; } - renderPromoOffers(); renderPromoSection(); renderBalanceSection(); renderTransactionHistory(); @@ -4290,18 +3734,6 @@ updateActionButtons(); } - document.getElementById('promoOfferContainer')?.addEventListener('click', event => { - const button = event.target.closest('[data-action="claim-promo-offer"]'); - if (!button) { - return; - } - const offerId = Number(button.dataset.offerId); - if (!Number.isFinite(offerId)) { - return; - } - claimPromoOffer(offerId, button); - }); - document.querySelectorAll('.platform-btn').forEach(btn => { btn.addEventListener('click', () => { currentPlatform = btn.dataset.platform;