From acef77b0e8170407d21d11e15753e2c19e058d55 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 9 Oct 2025 03:44:54 +0300 Subject: [PATCH] Display promo group discounts in mini app --- app/webapi/routes/miniapp.py | 87 ++++++- app/webapi/schemas/miniapp.py | 24 ++ miniapp/index.html | 441 ++++++++++++++++++++++++++++++++++ 3 files changed, 548 insertions(+), 4 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 1a446032..09a130d1 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -9,8 +9,10 @@ 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.database.models import PromoGroup, Subscription, Transaction, User from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, @@ -26,6 +28,7 @@ from ..dependencies import get_db_session from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppDevice, + MiniAppAutoPromoGroupLevel, MiniAppPromoGroup, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, @@ -336,6 +339,27 @@ 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 + + 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), + **_extract_promo_discounts(group), + ) + ) response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, @@ -386,11 +410,66 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], - promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name) - if promo_group - else None, + promo_group=( + MiniAppPromoGroup( + id=promo_group.id, + name=promo_group.name, + **_extract_promo_discounts(promo_group), + ) + 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(), ) +def _safe_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _normalize_period_discounts( + raw: Optional[Dict[Any, Any]] +) -> Dict[int, int]: + if not isinstance(raw, dict): + return {} + + normalized: Dict[int, int] = {} + for key, value in raw.items(): + try: + period = int(key) + normalized[period] = int(value) + except (TypeError, ValueError): + continue + + return normalized + + +def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]: + if not group: + return { + "server_discount_percent": 0, + "traffic_discount_percent": 0, + "device_discount_percent": 0, + "period_discounts": {}, + "apply_discounts_to_addons": True, + } + + return { + "server_discount_percent": max(0, _safe_int(getattr(group, "server_discount_percent", 0))), + "traffic_discount_percent": max(0, _safe_int(getattr(group, "traffic_discount_percent", 0))), + "device_discount_percent": max(0, _safe_int(getattr(group, "device_discount_percent", 0))), + "period_discounts": _normalize_period_discounts(getattr(group, "period_discounts", None)), + "apply_discounts_to_addons": bool( + getattr(group, "apply_discounts_to_addons", True) + ), + } + + diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 3461e170..360e6fa5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -39,6 +39,26 @@ class MiniAppSubscriptionUser(BaseModel): class MiniAppPromoGroup(BaseModel): id: int name: str + server_discount_percent: int = 0 + traffic_discount_percent: int = 0 + device_discount_percent: int = 0 + period_discounts: Dict[int, int] = Field(default_factory=dict) + apply_discounts_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 + server_discount_percent: int = 0 + traffic_discount_percent: int = 0 + device_discount_percent: int = 0 + period_discounts: Dict[int, int] = Field(default_factory=dict) + apply_discounts_to_addons: bool = True class MiniAppConnectedServer(BaseModel): @@ -90,6 +110,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..66f5201a 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -552,6 +552,10 @@ border-bottom: none; } + .info-item.promo-group-info { + align-items: flex-start; + } + .info-item:hover { padding-left: 8px; background: rgba(var(--primary-rgb), 0.02); @@ -576,6 +580,173 @@ text-align: right; } + .info-item.promo-group-info .info-value { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + width: 100%; + } + + .promo-group-discounts { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + } + + /* 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: center; + 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 { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + + .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); + } + + .promo-discount-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid rgba(var(--primary-rgb), 0.12); + background: rgba(var(--primary-rgb), 0.08); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } + + .promo-discount-badge .promo-discount-value { + color: var(--text-primary); + } + + .promo-discount-badge.muted { + background: rgba(var(--primary-rgb), 0.05); + border-color: rgba(var(--primary-rgb), 0.08); + } + + .promo-discount-badge.muted .promo-discount-value { + color: var(--text-secondary); + } + + .promo-level-item.current .promo-discount-badge { + border-color: rgba(var(--primary-rgb), 0.35); + color: var(--primary); + } + + .promo-level-item.current .promo-discount-badge .promo-discount-value { + color: var(--primary); + } + + .promo-level-item.reached .promo-discount-badge { + border-color: rgba(16, 185, 129, 0.35); + color: var(--success); + } + + .promo-level-item.reached .promo-discount-badge .promo-discount-value { + color: var(--success); + } + + .promo-level-item.locked .promo-discount-badge { + opacity: 0.8; + } + /* Balance Card */ .balance-card { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05)); @@ -1242,6 +1413,21 @@ color: var(--text-secondary); } + :root[data-theme="dark"] .promo-discount-badge { + background: rgba(var(--primary-rgb), 0.12); + border-color: rgba(var(--primary-rgb), 0.2); + } + + :root[data-theme="dark"] .promo-discount-badge.muted { + background: rgba(148, 163, 184, 0.15); + border-color: rgba(148, 163, 184, 0.25); + color: rgba(226, 232, 240, 0.75); + } + + :root[data-theme="dark"] .promo-discount-badge.muted .promo-discount-value { + color: rgba(226, 232, 240, 0.75); + } + :root[data-theme="dark"] body, :root[data-theme="dark"] .card, :root[data-theme="dark"] .user-card, @@ -1380,6 +1566,13 @@ Type - +
+ Promo group +
+ - + +
+
Device Limit - @@ -1391,6 +1584,28 @@
+ +
+
+
+ + + + Promo Levels +
+
+
+
+ Total spent + +
+
    + +
    +
    +