From e39822e732e1dd493d54af39dfa62d17a33e4bf6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 9 Oct 2025 04:00:18 +0300 Subject: [PATCH] Improve promo group UI with collapsible discounts --- app/webapi/routes/miniapp.py | 87 +++- app/webapi/schemas/miniapp.py | 24 + miniapp/index.html | 853 ++++++++++++++++++++++++++++++++++ 3 files changed, 960 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..dd12503f 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -560,6 +560,10 @@ border-radius: var(--radius-sm); } + .info-item.promo-group-info { + align-items: flex-start; + } + .info-label { font-size: 14px; color: var(--text-secondary); @@ -576,6 +580,311 @@ text-align: right; } + .promo-group-value { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + width: 100%; + } + + .promo-group-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + font-size: 13px; + font-weight: 600; + border: 1px solid rgba(var(--primary-rgb), 0.16); + min-width: 140px; + text-align: center; + } + + .promo-group-chip.muted { + color: var(--text-secondary); + background: rgba(var(--primary-rgb), 0.05); + border-color: rgba(var(--primary-rgb), 0.1); + } + + .promo-group-discounts, + .promo-discount-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + } + + .promo-group-period-discounts, + .promo-period-discounts { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-end; + } + + .promo-periods-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-secondary); + } + + .promo-group-period-discounts .promo-periods-title { + width: 100%; + text-align: right; + } + + .promo-period-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + } + + .promo-period-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.08); + border: 1px solid rgba(var(--primary-rgb), 0.12); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; + } + + .promo-period-badge .promo-period-value { + color: var(--primary); + } + + .promo-group-note { + font-size: 12px; + color: var(--warning); + background: rgba(245, 158, 11, 0.08); + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid rgba(245, 158, 11, 0.25); + max-width: 260px; + text-align: right; + } + + .promo-periods-empty { + font-size: 12px; + color: var(--text-secondary); + text-align: right; + } + + /* Promo levels */ + .promo-levels-card.expandable .card-content { + padding-bottom: 0; + } + + .promo-levels-card.expanded .card-content { + padding-bottom: 16px; + } + + .promo-levels-card .card-header { + align-items: flex-start; + } + + .promo-levels-card .card-header .expand-icon { + margin-top: 4px; + } + + .promo-header-content { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + } + + .promo-levels-header-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .promo-levels-header-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.08); + border: 1px solid rgba(var(--primary-rgb), 0.12); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + } + + .promo-levels-header-chip.muted { + color: var(--text-secondary); + background: rgba(var(--primary-rgb), 0.05); + } + + .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 { + 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-direction: column; + gap: 8px; + margin-top: 8px; + } + + .promo-period-discounts .promo-periods-title { + align-self: flex-start; + } + + .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 +1551,45 @@ 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"] .promo-group-chip, + :root[data-theme="dark"] .promo-levels-header-chip, + :root[data-theme="dark"] .promo-period-badge { + background: rgba(var(--primary-rgb), 0.16); + border-color: rgba(var(--primary-rgb), 0.28); + color: var(--text-primary); + } + + :root[data-theme="dark"] .promo-group-chip.muted, + :root[data-theme="dark"] .promo-levels-header-chip.muted { + background: rgba(148, 163, 184, 0.18); + border-color: rgba(148, 163, 184, 0.28); + color: var(--text-secondary); + } + + :root[data-theme="dark"] .promo-period-badge .promo-period-value { + color: var(--primary); + } + + :root[data-theme="dark"] .promo-group-note { + background: rgba(245, 158, 11, 0.18); + border-color: rgba(245, 158, 11, 0.45); + } + :root[data-theme="dark"] body, :root[data-theme="dark"] .card, :root[data-theme="dark"] .user-card, @@ -1380,6 +1728,15 @@ Type - +
+ Promo group +
+ - + + + +
+
Device Limit - @@ -1391,6 +1748,38 @@
+ + +