mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
Merge pull request #948 from Fr1ngg/ess9tg-bedolaga/add-user-promotion-group-display
Improve promo group UI with collapsible discounts
This commit is contained in:
@@ -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)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
<span class="info-label" data-i18n="info.subscription_type">Type</span>
|
||||
<span class="info-value" id="subscriptionType">-</span>
|
||||
</div>
|
||||
<div class="info-item promo-group-info">
|
||||
<span class="info-label" data-i18n="info.promo_group">Promo group</span>
|
||||
<div class="info-value promo-group-value">
|
||||
<span class="promo-group-chip" id="promoGroupValue">-</span>
|
||||
<div class="promo-group-discounts hidden" id="promoGroupDiscounts"></div>
|
||||
<div class="promo-group-period-discounts hidden" id="promoGroupPeriodDiscounts"></div>
|
||||
<div class="promo-group-note hidden" id="promoGroupAddonsNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
|
||||
<span class="info-value" id="deviceLimit">-</span>
|
||||
@@ -1391,6 +1748,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promo Levels Card -->
|
||||
<div class="card expandable promo-levels-card" id="promoLevelsCard">
|
||||
<div class="card-header">
|
||||
<div class="promo-header-content">
|
||||
<div class="card-title">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm0 0v13m-4-5l4 2 4-2"/>
|
||||
</svg>
|
||||
<span data-i18n="card.promo_levels.title">Promo Levels</span>
|
||||
</div>
|
||||
<div class="promo-levels-header-meta">
|
||||
<span class="promo-levels-header-chip hidden" id="promoLevelsHeaderGroup">—</span>
|
||||
<span class="promo-levels-header-chip hidden" id="promoLevelsHeaderCurrent">—</span>
|
||||
<span class="promo-levels-header-chip muted hidden" id="promoLevelsHeaderNext">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="promo-level-summary">
|
||||
<span class="promo-level-summary-label" data-i18n="promo_levels.total_spent">Total spent</span>
|
||||
<span class="promo-level-summary-value" id="promoLevelsSpent">—</span>
|
||||
</div>
|
||||
<ul class="promo-level-list" id="promoLevelsList"></ul>
|
||||
<div class="empty-state hidden" id="promoLevelsEmpty" data-i18n="promo_levels.empty">
|
||||
Automatic promo levels are not configured yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="connectBtn">
|
||||
@@ -1671,6 +2060,7 @@
|
||||
'card.history.title': 'Transaction History',
|
||||
'card.servers.title': 'Connected Servers',
|
||||
'card.devices.title': 'Connected Devices',
|
||||
'card.promo_levels.title': 'Promo Levels',
|
||||
'apps.title': 'Installation guide',
|
||||
'apps.no_data': 'No installation guide available for this platform yet.',
|
||||
'apps.featured': 'Recommended',
|
||||
@@ -1687,6 +2077,24 @@
|
||||
'history.type.referral_reward': 'Referral reward',
|
||||
'servers.empty': 'No servers connected yet',
|
||||
'devices.empty': 'No devices connected yet',
|
||||
'promo_levels.total_spent': 'Total spent',
|
||||
'promo_levels.threshold': 'from {amount}',
|
||||
'promo_levels.badge.current': 'Current level',
|
||||
'promo_levels.badge.unlocked': 'Unlocked',
|
||||
'promo_levels.badge.locked': 'Locked',
|
||||
'promo_levels.discounts.server': 'Servers',
|
||||
'promo_levels.discounts.traffic': 'Traffic',
|
||||
'promo_levels.discounts.devices': 'Devices',
|
||||
'promo_levels.discounts.none': 'No discounts',
|
||||
'promo_levels.empty': 'Automatic promo levels are not configured yet',
|
||||
'promo_levels.header.current': 'Current: {name}',
|
||||
'promo_levels.header.current.none': 'No promo level yet',
|
||||
'promo_levels.header.next': 'Next at {amount}',
|
||||
'promo_levels.header.max': 'All promo levels unlocked',
|
||||
'promo_group.none': 'No promo group',
|
||||
'promo_group.period_discounts.title': 'Subscription period discounts',
|
||||
'promo_group.period_discounts.empty': 'No period discounts yet',
|
||||
'promo_group.addons.disabled': 'Add-on discounts are disabled',
|
||||
'language.ariaLabel': 'Select interface language',
|
||||
'notifications.copy.success': 'Subscription link copied to clipboard.',
|
||||
'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.',
|
||||
@@ -1735,6 +2143,7 @@
|
||||
'card.history.title': 'История операций',
|
||||
'card.servers.title': 'Подключённые серверы',
|
||||
'card.devices.title': 'Подключенные устройства',
|
||||
'card.promo_levels.title': 'Уровни промогрупп',
|
||||
'apps.title': 'Инструкция по установке',
|
||||
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
|
||||
'apps.featured': 'Рекомендуем',
|
||||
@@ -1751,6 +2160,24 @@
|
||||
'history.type.referral_reward': 'Реферальное вознаграждение',
|
||||
'servers.empty': 'Подключённых серверов пока нет',
|
||||
'devices.empty': 'Подключённых устройств пока нет',
|
||||
'promo_levels.total_spent': 'Всего потрачено',
|
||||
'promo_levels.threshold': 'от {amount}',
|
||||
'promo_levels.badge.current': 'Текущий уровень',
|
||||
'promo_levels.badge.unlocked': 'Получен',
|
||||
'promo_levels.badge.locked': 'Недоступен',
|
||||
'promo_levels.discounts.server': 'Серверы',
|
||||
'promo_levels.discounts.traffic': 'Трафик',
|
||||
'promo_levels.discounts.devices': 'Устройства',
|
||||
'promo_levels.discounts.none': 'Скидок нет',
|
||||
'promo_levels.empty': 'Автовыдача промогрупп ещё не настроена',
|
||||
'promo_levels.header.current': 'Текущий уровень: {name}',
|
||||
'promo_levels.header.current.none': 'Уровень ещё не получен',
|
||||
'promo_levels.header.next': 'Следующий от {amount}',
|
||||
'promo_levels.header.max': 'Все уровни уже получены',
|
||||
'promo_group.none': 'Без промогруппы',
|
||||
'promo_group.period_discounts.title': 'Скидки на периоды подписки',
|
||||
'promo_group.period_discounts.empty': 'Скидок на периоды пока нет',
|
||||
'promo_group.addons.disabled': 'Скидки на доп. услуги не применяются',
|
||||
'language.ariaLabel': 'Выберите язык интерфейса',
|
||||
'notifications.copy.success': 'Ссылка подписки скопирована.',
|
||||
'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.',
|
||||
@@ -2289,6 +2716,8 @@
|
||||
: autopayLabel;
|
||||
}
|
||||
|
||||
renderPromoGroupInfo();
|
||||
renderPromoLevels();
|
||||
renderBalanceSection();
|
||||
renderTransactionHistory();
|
||||
renderServersList();
|
||||
@@ -2495,6 +2924,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatPriceFromKopeks(kopeks, currency) {
|
||||
const normalized = typeof kopeks === 'number'
|
||||
? kopeks
|
||||
: Number.parseInt(String(kopeks ?? '').trim() || '0', 10);
|
||||
const currencyCode = currency
|
||||
? String(currency).toUpperCase()
|
||||
: String(userData?.balance_currency || 'RUB').toUpperCase();
|
||||
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return formatCurrency(0, currencyCode);
|
||||
}
|
||||
|
||||
return formatCurrency(normalized / 100, currencyCode);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
@@ -2695,6 +3139,415 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const PROMO_DISCOUNT_FIELDS = [
|
||||
{ field: 'server_discount_percent', labelKey: 'promo_levels.discounts.server' },
|
||||
{ field: 'traffic_discount_percent', labelKey: 'promo_levels.discounts.traffic' },
|
||||
{ field: 'device_discount_percent', labelKey: 'promo_levels.discounts.devices' },
|
||||
];
|
||||
|
||||
function normalizePromoPercent(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function populatePromoDiscounts(container, source, options = {}) {
|
||||
const { showEmptyMessage = false } = options;
|
||||
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!source) {
|
||||
container.classList.add('hidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasDiscount = false;
|
||||
|
||||
PROMO_DISCOUNT_FIELDS.forEach(({ field, labelKey }) => {
|
||||
const percentValue = normalizePromoPercent(source?.[field]);
|
||||
if (percentValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = Math.max(0, Math.round(percentValue));
|
||||
if (normalized <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasDiscount = true;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'promo-discount-badge';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'promo-discount-label';
|
||||
label.textContent = t(labelKey);
|
||||
|
||||
const valueElement = document.createElement('span');
|
||||
valueElement.className = 'promo-discount-value';
|
||||
valueElement.textContent = `${normalized}%`;
|
||||
|
||||
badge.appendChild(label);
|
||||
badge.appendChild(valueElement);
|
||||
container.appendChild(badge);
|
||||
});
|
||||
|
||||
if (!hasDiscount) {
|
||||
if (showEmptyMessage) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'promo-discount-badge muted';
|
||||
badge.textContent = t('promo_levels.discounts.none');
|
||||
container.appendChild(badge);
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
container.classList.remove('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPeriodDiscountEntries(source) {
|
||||
const raw = source?.period_discounts;
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
Object.entries(raw).forEach(([key, value]) => {
|
||||
const days = Number.parseInt(key, 10);
|
||||
const percentValue = normalizePromoPercent(value);
|
||||
if (!Number.isFinite(days) || percentValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = Math.max(0, Math.round(percentValue));
|
||||
if (normalized <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.push({ days, percent: normalized });
|
||||
});
|
||||
|
||||
entries.sort((a, b) => a.days - b.days);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatPeriodDaysLabel(days) {
|
||||
const numericDays = Number.parseInt(days, 10);
|
||||
if (!Number.isFinite(numericDays)) {
|
||||
return String(days);
|
||||
}
|
||||
|
||||
const lang = (preferredLanguage || 'en').toLowerCase();
|
||||
if (lang.startsWith('ru')) {
|
||||
const mod10 = numericDays % 10;
|
||||
const mod100 = numericDays % 100;
|
||||
let suffix = 'дней';
|
||||
if (mod10 === 1 && mod100 !== 11) {
|
||||
suffix = 'день';
|
||||
} else if ([2, 3, 4].includes(mod10) && !(mod100 >= 12 && mod100 <= 14)) {
|
||||
suffix = 'дня';
|
||||
}
|
||||
return `${numericDays} ${suffix}`;
|
||||
}
|
||||
|
||||
if (lang.startsWith('uk')) {
|
||||
const mod10 = numericDays % 10;
|
||||
const mod100 = numericDays % 100;
|
||||
let suffix = 'днів';
|
||||
if (mod10 === 1 && mod100 !== 11) {
|
||||
suffix = 'день';
|
||||
} else if ([2, 3, 4].includes(mod10) && !(mod100 >= 12 && mod100 <= 14)) {
|
||||
suffix = 'дні';
|
||||
}
|
||||
return `${numericDays} ${suffix}`;
|
||||
}
|
||||
|
||||
return `${numericDays} day${numericDays === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function populatePromoPeriodDiscounts(container, source, options = {}) {
|
||||
const { showTitle = false, showEmptyMessage = false } = options;
|
||||
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const entries = getPeriodDiscountEntries(source);
|
||||
|
||||
if (showTitle) {
|
||||
const title = document.createElement('div');
|
||||
title.className = 'promo-periods-title';
|
||||
title.textContent = t('promo_group.period_discounts.title');
|
||||
container.appendChild(title);
|
||||
}
|
||||
|
||||
if (!entries.length) {
|
||||
if (showEmptyMessage) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'promo-periods-empty';
|
||||
empty.textContent = t('promo_group.period_discounts.empty');
|
||||
container.appendChild(empty);
|
||||
container.classList.remove('hidden');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const badgesWrapper = document.createElement('div');
|
||||
badgesWrapper.className = 'promo-period-badges';
|
||||
|
||||
entries.forEach(({ days, percent }) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'promo-period-badge';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'promo-period-label';
|
||||
label.textContent = formatPeriodDaysLabel(days);
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.className = 'promo-period-value';
|
||||
value.textContent = `-${percent}%`;
|
||||
|
||||
badge.appendChild(label);
|
||||
badge.appendChild(value);
|
||||
badgesWrapper.appendChild(badge);
|
||||
});
|
||||
|
||||
container.appendChild(badgesWrapper);
|
||||
container.classList.remove('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderPromoGroupInfo() {
|
||||
const valueElement = document.getElementById('promoGroupValue');
|
||||
const discountsContainer = document.getElementById('promoGroupDiscounts');
|
||||
const periodDiscountsContainer = document.getElementById('promoGroupPeriodDiscounts');
|
||||
const addonsNote = document.getElementById('promoGroupAddonsNote');
|
||||
if (!valueElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promoGroup = userData?.promo_group;
|
||||
const promoGroupName = promoGroup?.name;
|
||||
const hasPromoGroup = Boolean(promoGroupName);
|
||||
const noGroupLabel = t('promo_group.none');
|
||||
valueElement.textContent = hasPromoGroup
|
||||
? promoGroupName
|
||||
: (noGroupLabel === 'promo_group.none' ? t('values.not_available') : noGroupLabel);
|
||||
valueElement.classList.toggle('muted', !hasPromoGroup);
|
||||
|
||||
if (discountsContainer) {
|
||||
const hasDiscounts = populatePromoDiscounts(discountsContainer, promoGroup, {
|
||||
showEmptyMessage: true,
|
||||
});
|
||||
discountsContainer.classList.toggle('hidden', !hasDiscounts);
|
||||
}
|
||||
|
||||
if (periodDiscountsContainer) {
|
||||
const hasPeriods = populatePromoPeriodDiscounts(periodDiscountsContainer, promoGroup, {
|
||||
showTitle: true,
|
||||
showEmptyMessage: false,
|
||||
});
|
||||
periodDiscountsContainer.classList.toggle('hidden', !hasPeriods);
|
||||
}
|
||||
|
||||
if (addonsNote) {
|
||||
const applyAddons = promoGroup?.apply_discounts_to_addons !== false;
|
||||
if (promoGroup && !applyAddons) {
|
||||
const text = t('promo_group.addons.disabled');
|
||||
addonsNote.textContent = text === 'promo_group.addons.disabled'
|
||||
? 'Add-on discounts are disabled'
|
||||
: text;
|
||||
addonsNote.classList.remove('hidden');
|
||||
} else {
|
||||
addonsNote.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const headerGroup = document.getElementById('promoLevelsHeaderGroup');
|
||||
if (headerGroup) {
|
||||
if (hasPromoGroup) {
|
||||
headerGroup.textContent = promoGroupName;
|
||||
headerGroup.classList.remove('muted', 'hidden');
|
||||
} else {
|
||||
const fallback = noGroupLabel === 'promo_group.none'
|
||||
? t('values.not_available')
|
||||
: noGroupLabel;
|
||||
headerGroup.textContent = fallback;
|
||||
headerGroup.classList.add('muted');
|
||||
headerGroup.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPromoLevels() {
|
||||
const list = document.getElementById('promoLevelsList');
|
||||
const emptyState = document.getElementById('promoLevelsEmpty');
|
||||
const totalSpentElement = document.getElementById('promoLevelsSpent');
|
||||
const card = document.getElementById('promoLevelsCard');
|
||||
const headerCurrent = document.getElementById('promoLevelsHeaderCurrent');
|
||||
const headerNext = document.getElementById('promoLevelsHeaderNext');
|
||||
|
||||
if (!list || !emptyState || !totalSpentElement || !card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levels = Array.isArray(userData?.auto_assign_promo_groups)
|
||||
? userData.auto_assign_promo_groups
|
||||
: [];
|
||||
|
||||
const totalSpentKopeksRaw = typeof userData?.total_spent_kopeks === 'number'
|
||||
? userData.total_spent_kopeks
|
||||
: Number.parseInt(userData?.total_spent_kopeks ?? '0', 10);
|
||||
const totalSpentKopeks = Number.isFinite(totalSpentKopeksRaw) ? totalSpentKopeksRaw : 0;
|
||||
totalSpentElement.textContent = userData?.total_spent_label
|
||||
|| formatPriceFromKopeks(totalSpentKopeks);
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!levels.length) {
|
||||
card.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
if (headerCurrent) {
|
||||
headerCurrent.classList.add('hidden');
|
||||
}
|
||||
if (headerNext) {
|
||||
headerNext.classList.add('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
card.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
const currencyCode = (userData?.balance_currency || 'RUB').toUpperCase();
|
||||
const thresholdTemplate = t('promo_levels.threshold');
|
||||
|
||||
if (headerCurrent) {
|
||||
const currentLevel = levels.find(level => level?.is_current) || levels.find(level => level?.is_reached);
|
||||
if (currentLevel) {
|
||||
const template = t('promo_levels.header.current');
|
||||
const label = template === 'promo_levels.header.current'
|
||||
? `Current: ${currentLevel.name || t('values.not_available')}`
|
||||
: template.replace('{name}', currentLevel.name || t('values.not_available'));
|
||||
headerCurrent.textContent = label;
|
||||
headerCurrent.classList.remove('muted', 'hidden');
|
||||
} else {
|
||||
const template = t('promo_levels.header.current.none');
|
||||
headerCurrent.textContent = template === 'promo_levels.header.current.none'
|
||||
? 'No promo level yet'
|
||||
: template;
|
||||
headerCurrent.classList.add('muted');
|
||||
headerCurrent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (headerNext) {
|
||||
const nextLevel = levels.find(level => !level?.is_reached);
|
||||
if (nextLevel) {
|
||||
const nextThreshold = nextLevel.threshold_label
|
||||
|| formatPriceFromKopeks(nextLevel.threshold_kopeks, currencyCode);
|
||||
const template = t('promo_levels.header.next');
|
||||
headerNext.textContent = template === 'promo_levels.header.next'
|
||||
? `Next at ${nextThreshold}`
|
||||
: template.replace('{amount}', nextThreshold);
|
||||
headerNext.classList.remove('muted', 'hidden');
|
||||
} else {
|
||||
const template = t('promo_levels.header.max');
|
||||
headerNext.textContent = template === 'promo_levels.header.max'
|
||||
? 'All promo levels unlocked'
|
||||
: template;
|
||||
headerNext.classList.add('muted');
|
||||
headerNext.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
levels.forEach(level => {
|
||||
const classes = ['promo-level-item'];
|
||||
if (level?.is_current) {
|
||||
classes.push('current', 'reached');
|
||||
} else if (level?.is_reached) {
|
||||
classes.push('reached');
|
||||
} else {
|
||||
classes.push('locked');
|
||||
}
|
||||
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.join(' ');
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'promo-level-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'promo-level-name';
|
||||
name.textContent = level?.name || t('values.not_available');
|
||||
info.appendChild(name);
|
||||
|
||||
const threshold = document.createElement('div');
|
||||
threshold.className = 'promo-level-threshold';
|
||||
const thresholdLabel = level?.threshold_label
|
||||
|| formatPriceFromKopeks(level?.threshold_kopeks, currencyCode);
|
||||
threshold.textContent = thresholdTemplate.includes('{amount}')
|
||||
? thresholdTemplate.replace('{amount}', thresholdLabel)
|
||||
: `${thresholdTemplate} ${thresholdLabel}`;
|
||||
info.appendChild(threshold);
|
||||
|
||||
const discounts = document.createElement('div');
|
||||
discounts.className = 'promo-level-discounts';
|
||||
|
||||
const discountRow = document.createElement('div');
|
||||
discountRow.className = 'promo-discount-row';
|
||||
const hasBaseDiscounts = populatePromoDiscounts(discountRow, level);
|
||||
if (hasBaseDiscounts) {
|
||||
discounts.appendChild(discountRow);
|
||||
}
|
||||
|
||||
const periodContainer = document.createElement('div');
|
||||
periodContainer.className = 'promo-period-discounts';
|
||||
const hasPeriodDiscounts = populatePromoPeriodDiscounts(periodContainer, level);
|
||||
if (hasPeriodDiscounts) {
|
||||
discounts.appendChild(periodContainer);
|
||||
}
|
||||
|
||||
if (discounts.childElementCount > 0) {
|
||||
info.appendChild(discounts);
|
||||
}
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'promo-level-badge';
|
||||
let badgeKey = 'promo_levels.badge.locked';
|
||||
if (level?.is_current) {
|
||||
badgeKey = 'promo_levels.badge.current';
|
||||
} else if (level?.is_reached) {
|
||||
badgeKey = 'promo_levels.badge.unlocked';
|
||||
}
|
||||
badge.textContent = t(badgeKey);
|
||||
|
||||
item.appendChild(info);
|
||||
item.appendChild(badge);
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentSubscriptionUrl() {
|
||||
return userData?.subscription_url || userData?.subscriptionUrl || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user