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 @@ +
+