diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9ebdec4b..12abb9ca 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2055,6 +2055,20 @@ async def _build_referral_info( ) +def _is_trial_available_for_user(user: User) -> bool: + if settings.TRIAL_DURATION_DAYS <= 0: + return False + + if getattr(user, "has_had_paid_subscription", False): + return False + + subscription = getattr(user, "subscription", None) + if subscription is not None: + return False + + return True + + @router.post("/subscription", response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, @@ -2085,40 +2099,23 @@ async def get_subscription_details( 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 not user: + detail: Dict[str, Any] = { + "code": "user_not_found", + "message": "User not found. Please register in the bot to continue.", + "title": "Registration required", + } if purchase_url: - detail = { - "message": "Subscription not found", - "purchase_url": purchase_url, - } + detail["purchase_url"] = purchase_url raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=detail, ) - subscription = user.subscription - traffic_used = _format_gb(subscription.traffic_used_gb) - traffic_limit = subscription.traffic_limit_gb or 0 + subscription = getattr(user, "subscription", None) lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) - status_actual = subscription.actual_status - links_payload = await _load_subscription_links(subscription) - - subscription_url = links_payload.get("subscription_url") or subscription.subscription_url - subscription_crypto_link = ( - links_payload.get("happ_crypto_link") - or subscription.subscription_crypto_link - ) - - happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) - - connected_squads: List[str] = list(subscription.connected_squads or []) - connected_servers = await _resolve_connected_servers(db, connected_squads) - devices_count, devices = await _load_devices_info(user) - links: List[str] = links_payload.get("links") or connected_squads - ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} - transactions_query = ( select(Transaction) .where(Transaction.user_id == user.id) @@ -2186,7 +2183,10 @@ async def get_subscription_details( ) ) - active_offer_contexts.extend(await _find_active_test_access_offers(db, subscription)) + if subscription: + active_offer_contexts.extend( + await _find_active_test_access_offers(db, subscription) + ) promo_offers = await _build_promo_offer_models( db, @@ -2312,6 +2312,46 @@ async def get_subscription_details( updated_at=getattr(service_rules, "updated_at", None), ) + links_payload: Dict[str, Any] = {} + connected_squads: List[str] = [] + connected_servers: List[MiniAppConnectedServer] = [] + links: List[str] = [] + ss_conf_links: Dict[str, str] = {} + subscription_url: Optional[str] = None + subscription_crypto_link: Optional[str] = None + happ_redirect_link: Optional[str] = None + remnawave_short_uuid: Optional[str] = None + status_actual = "missing" + subscription_status_value = "none" + traffic_used_value = 0.0 + traffic_limit_value = 0 + device_limit_value: Optional[int] = settings.DEFAULT_DEVICE_LIMIT or None + autopay_enabled = False + + if subscription: + traffic_used_value = _format_gb(subscription.traffic_used_gb) + traffic_limit_value = subscription.traffic_limit_gb or 0 + status_actual = subscription.actual_status + subscription_status_value = subscription.status + links_payload = await _load_subscription_links(subscription) + subscription_url = ( + links_payload.get("subscription_url") or subscription.subscription_url + ) + subscription_crypto_link = ( + links_payload.get("happ_crypto_link") + or subscription.subscription_crypto_link + ) + happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) + connected_squads = list(subscription.connected_squads or []) + connected_servers = await _resolve_connected_servers(db, connected_squads) + links = links_payload.get("links") or connected_squads + ss_conf_links = links_payload.get("ss_conf_links") or {} + remnawave_short_uuid = subscription.remnawave_short_uuid + device_limit_value = subscription.device_limit + autopay_enabled = bool(subscription.autopay_enabled) + + devices_count, devices = await _load_devices_info(user) + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -2327,15 +2367,15 @@ async def get_subscription_details( ), language=user.language, status=user.status, - subscription_status=subscription.status, + subscription_status=subscription_status_value, subscription_actual_status=status_actual, status_label=_status_label(status_actual), - expires_at=subscription.end_date, - device_limit=subscription.device_limit, - traffic_used_gb=round(traffic_used, 2), - traffic_used_label=_format_gb_label(traffic_used), - traffic_limit_gb=traffic_limit, - traffic_limit_label=_format_limit_label(traffic_limit), + expires_at=getattr(subscription, "end_date", None), + device_limit=device_limit_value, + traffic_used_gb=round(traffic_used_value, 2), + traffic_used_label=_format_gb_label(traffic_used_value), + traffic_limit_gb=traffic_limit_value, + traffic_limit_label=_format_limit_label(traffic_limit_value), lifetime_used_traffic_gb=lifetime_used, has_active_subscription=status_actual in {"active", "trial"}, promo_offer_discount_percent=active_discount_percent, @@ -2345,9 +2385,14 @@ async def get_subscription_details( referral_info = await _build_referral_info(db, user) + trial_available = _is_trial_available_for_user(user) + trial_duration_days = ( + settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None + ) + return MiniAppSubscriptionResponse( - subscription_id=subscription.id, - remnawave_short_uuid=subscription.remnawave_short_uuid, + subscription_id=getattr(subscription, "id", None), + remnawave_short_uuid=remnawave_short_uuid, user=response_user, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, @@ -2358,9 +2403,9 @@ async def get_subscription_details( connected_servers=connected_servers, connected_devices_count=devices_count, connected_devices=devices, - happ=links_payload.get("happ"), - happ_link=links_payload.get("happ_link"), - happ_crypto_link=links_payload.get("happ_crypto_link"), + happ=links_payload.get("happ") if subscription else None, + happ_link=links_payload.get("happ_link") if subscription else None, + happ_crypto_link=links_payload.get("happ_crypto_link") if subscription else None, happ_cryptolink_redirect_link=happ_redirect_link, balance_kopeks=user.balance_kopeks, balance_rubles=round(user.balance_rubles, 2), @@ -2380,12 +2425,21 @@ async def get_subscription_details( total_spent_kopeks=total_spent_kopeks, total_spent_rubles=round(total_spent_kopeks / 100, 2), total_spent_label=settings.format_price(total_spent_kopeks), - subscription_type="trial" if subscription.is_trial else "paid", - autopay_enabled=bool(subscription.autopay_enabled), + subscription_type=( + "trial" + if subscription and subscription.is_trial + else ("paid" if subscription else "none") + ), + autopay_enabled=autopay_enabled, branding=settings.get_miniapp_branding(), faq=faq_payload, legal_documents=legal_documents_payload, referral=referral_info, + subscription_missing=subscription is None, + subscription_missing_reason="not_found" if subscription is None else None, + trial_available=trial_available, + trial_duration_days=trial_duration_days, + trial_status="available" if trial_available else "unavailable", ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 243a2fae..5541b4da 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -383,7 +383,7 @@ class MiniAppPaymentStatusResponse(BaseModel): class MiniAppSubscriptionResponse(BaseModel): success: bool = True - subscription_id: int + subscription_id: Optional[int] = None remnawave_short_uuid: Optional[str] = None user: MiniAppSubscriptionUser subscription_url: Optional[str] = None @@ -415,6 +415,11 @@ class MiniAppSubscriptionResponse(BaseModel): faq: Optional[MiniAppFaq] = None legal_documents: Optional[MiniAppLegalDocuments] = None referral: Optional[MiniAppReferralInfo] = None + subscription_missing: bool = False + subscription_missing_reason: Optional[str] = None + trial_available: bool = False + trial_duration_days: Optional[int] = None + trial_status: Optional[str] = None class MiniAppSubscriptionServerOption(BaseModel): diff --git a/miniapp/index.html b/miniapp/index.html index f99c02b6..dbfb1e98 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -331,6 +331,16 @@ min-width: 220px; } + .error.error-user-missing { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.14), rgba(var(--primary-rgb), 0.08)); + box-shadow: var(--shadow-md); + border: 1px solid rgba(var(--primary-rgb), 0.25); + } + + .error.error-user-missing .error-text { + color: var(--text-primary); + } + /* Cards */ .card { background: var(--bg-secondary); @@ -1781,6 +1791,21 @@ background: rgba(255, 255, 255, 0.2); } + :root[data-theme="dark"] .subscription-missing-card { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0.12)); + border-color: rgba(var(--primary-rgb), 0.5); + } + + :root[data-theme="dark"] .subscription-missing-icon { + background: rgba(var(--primary-rgb), 0.3); + color: #bfdbfe; + } + + :root[data-theme="dark"] .status-missing { + background: rgba(59, 130, 246, 0.18); + color: #bfdbfe; + } + :root[data-theme="dark"] .promo-offer-chip { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); @@ -1832,6 +1857,64 @@ min-width: 0; } + .subscription-missing-card { + display: flex; + gap: 16px; + padding: 20px; + align-items: center; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.12)); + border: 2px dashed rgba(var(--primary-rgb), 0.35); + } + + .subscription-missing-icon { + width: 56px; + height: 56px; + border-radius: 16px; + background: rgba(var(--primary-rgb), 0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + flex-shrink: 0; + color: var(--primary); + } + + .subscription-missing-content { + flex: 1; + min-width: 0; + } + + .subscription-missing-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; + color: var(--text-primary); + } + + .subscription-missing-description { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 12px; + } + + .subscription-missing-hint { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; + } + + .subscription-missing-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .subscription-missing-actions .btn-primary, + .subscription-missing-actions .btn-secondary { + flex: 1; + min-width: 140px; + } + .user-name { font-size: 20px; font-weight: 700; @@ -1882,6 +1965,11 @@ color: #41464b; } + .status-missing { + background: linear-gradient(135deg, #e0e7ff, #eef2ff); + color: #1e3a8a; + } + /* Stats Grid */ .stats-grid { display: grid; @@ -4210,7 +4298,7 @@