diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 1a446032..1b4a43ae 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.server_squad import get_server_squad_by_uuid +from app.database.crud.promo_group import get_auto_assign_promo_groups +from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import get_user_by_telegram_id from app.database.models import Subscription, Transaction, User from app.services.remnawave_service import ( @@ -26,7 +28,9 @@ from ..dependencies import get_db_session from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, + MiniAppAutoPromoGroupLevel, MiniAppPromoGroup, + MiniAppPromoGroupDiscounts, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -336,6 +340,53 @@ async def get_subscription_details( balance_currency = balance_currency.upper() promo_group = getattr(user, "promo_group", None) + total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id) + auto_assign_groups = await get_auto_assign_promo_groups(db) + + auto_promo_levels: List[MiniAppAutoPromoGroupLevel] = [] + for group in auto_assign_groups: + threshold = group.auto_assign_total_spent_kopeks or 0 + if threshold <= 0: + continue + + period_discounts_map: Dict[int, int] = {} + normalized_periods: List[Tuple[int, int]] = [] + if isinstance(group.period_discounts, dict): + for raw_days, raw_percent in group.period_discounts.items(): + try: + days = int(raw_days) + percent = int(raw_percent) + except (TypeError, ValueError): + continue + + normalized_percent = max(0, min(100, percent)) + if normalized_percent > 0 and days > 0: + normalized_periods.append((days, normalized_percent)) + + if normalized_periods: + normalized_periods.sort(key=lambda item: item[0]) + period_discounts_map = {days: percent for days, percent in normalized_periods} + + discounts = MiniAppPromoGroupDiscounts( + servers_percent=max(0, group.server_discount_percent or 0), + traffic_percent=max(0, group.traffic_discount_percent or 0), + devices_percent=max(0, group.device_discount_percent or 0), + period_discounts=period_discounts_map, + applies_to_addons=bool(group.apply_discounts_to_addons), + ) + + auto_promo_levels.append( + MiniAppAutoPromoGroupLevel( + id=group.id, + name=group.name, + threshold_kopeks=threshold, + threshold_rubles=round(threshold / 100, 2), + threshold_label=settings.format_price(threshold), + is_reached=total_spent_kopeks >= threshold, + is_current=bool(promo_group and promo_group.id == group.id), + discounts=discounts, + ) + ) response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, @@ -389,6 +440,10 @@ async def get_subscription_details( promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name) if promo_group else None, + auto_assign_promo_groups=auto_promo_levels, + 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), branding=settings.get_miniapp_branding(), diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 3461e170..449f88d8 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -41,6 +41,27 @@ class MiniAppPromoGroup(BaseModel): name: str +class MiniAppPromoGroupDiscounts(BaseModel): + servers_percent: int = 0 + traffic_percent: int = 0 + devices_percent: int = 0 + period_discounts: Dict[int, int] = Field(default_factory=dict) + applies_to_addons: bool = True + + +class MiniAppAutoPromoGroupLevel(BaseModel): + id: int + name: str + threshold_kopeks: int + threshold_rubles: float + threshold_label: str + is_reached: bool = False + is_current: bool = False + discounts: MiniAppPromoGroupDiscounts = Field( + default_factory=MiniAppPromoGroupDiscounts + ) + + class MiniAppConnectedServer(BaseModel): uuid: str name: str @@ -90,6 +111,10 @@ class MiniAppSubscriptionResponse(BaseModel): balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) promo_group: Optional[MiniAppPromoGroup] = None + auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list) + total_spent_kopeks: int = 0 + total_spent_rubles: float = 0.0 + total_spent_label: Optional[str] = None subscription_type: str autopay_enabled: bool = False branding: Optional[MiniAppBranding] = None diff --git a/miniapp/index.html b/miniapp/index.html index 29d0c127..c2127a3d 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -576,6 +576,139 @@ text-align: right; } + /* Promo levels */ + .promo-levels-card .card-content { + opacity: 1; + max-height: 2000px; + padding-bottom: 16px; + } + + .promo-level-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px 12px; + font-weight: 600; + color: var(--text-primary); + } + + .promo-level-summary-label { + color: var(--text-secondary); + font-size: 13px; + } + + .promo-level-summary-value { + font-size: 16px; + } + + .promo-level-list { + list-style: none; + padding: 0 20px 8px; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; + } + + .promo-level-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--bg-secondary); + transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease; + } + + .promo-level-item:hover { + border-color: rgba(var(--primary-rgb), 0.4); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); + } + + .promo-level-item.current { + border-color: var(--primary); + box-shadow: var(--shadow-sm); + } + + .promo-level-info { + display: flex; + flex-direction: column; + gap: 4px; + } + + .promo-level-discounts { + margin-top: 6px; + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + + .promo-level-discount-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + } + + .promo-level-discount-label { + font-weight: 600; + } + + .promo-level-discount-value { + padding: 2px 8px; + border-radius: var(--radius-sm); + background: rgba(var(--primary-rgb), 0.12); + color: var(--text-primary); + font-weight: 600; + letter-spacing: 0.2px; + } + + .promo-level-discount-item.is-empty { + justify-content: flex-start; + font-style: italic; + padding: 0; + } + + .promo-level-name { + font-weight: 700; + color: var(--text-primary); + font-size: 15px; + } + + .promo-level-threshold { + font-size: 13px; + color: var(--text-secondary); + } + + .promo-level-badge { + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.3px; + } + + .promo-level-item.current .promo-level-badge { + background: rgba(var(--primary-rgb), 0.18); + color: var(--primary); + } + + .promo-level-item.reached .promo-level-badge { + background: rgba(16, 185, 129, 0.18); + color: var(--success); + } + + .promo-level-item.locked .promo-level-badge { + background: rgba(148, 163, 184, 0.18); + color: var(--text-secondary); + } + /* Balance Card */ .balance-card { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05)); @@ -1380,6 +1513,10 @@ Type - +
+ Promo group + - +
Device Limit - @@ -1391,6 +1528,28 @@
+ +
+
+
+ + + + Promo Levels +
+
+
+
+ Total spent + +
+ + +
+
+