mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 21:01:17 +00:00
Merge pull request #938 from Fr1ngg/pwo411-bedolaga/add-user-promotion-group-display
Show promo group and auto levels in mini app
This commit is contained in:
@@ -9,8 +9,9 @@ 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.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 +27,7 @@ from ..dependencies import get_db_session
|
||||
from ..schemas.miniapp import (
|
||||
MiniAppConnectedServer,
|
||||
MiniAppDevice,
|
||||
MiniAppAutoPromoLevel,
|
||||
MiniAppPromoGroup,
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
@@ -63,6 +65,25 @@ def _format_limit_label(limit: Optional[int]) -> str:
|
||||
return f"{limit} GB"
|
||||
|
||||
|
||||
def _format_price_label(value: Optional[int]) -> str:
|
||||
try:
|
||||
kopeks = int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
kopeks = 0
|
||||
|
||||
formatter = getattr(settings, "format_price", None)
|
||||
if callable(formatter):
|
||||
try:
|
||||
return str(formatter(kopeks))
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
logger.debug("Failed to format price via settings.format_price", exc_info=True)
|
||||
|
||||
symbol = getattr(settings, "CURRENCY_SYMBOL", None) or getattr(settings, "BALANCE_CURRENCY_SYMBOL", None)
|
||||
symbol = (symbol or "₽").strip()
|
||||
amount = kopeks / 100
|
||||
return f"{amount:.2f} {symbol}".strip()
|
||||
|
||||
|
||||
def _bytes_to_gb(bytes_value: Optional[int]) -> float:
|
||||
if not bytes_value:
|
||||
return 0.0
|
||||
@@ -195,6 +216,42 @@ async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
|
||||
return total_devices, devices
|
||||
|
||||
|
||||
async def _load_auto_promo_levels(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
promo_group: Optional[PromoGroup],
|
||||
) -> Tuple[int, List[MiniAppAutoPromoLevel], Optional[str]]:
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id) or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(
|
||||
PromoGroup.auto_assign_total_spent_kopeks.asc(),
|
||||
PromoGroup.id.asc(),
|
||||
)
|
||||
)
|
||||
groups = list(result.scalars().all())
|
||||
|
||||
levels: List[MiniAppAutoPromoLevel] = []
|
||||
for group in groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
levels.append(
|
||||
MiniAppAutoPromoLevel(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
threshold_kopeks=threshold,
|
||||
threshold_label=_format_price_label(threshold) if threshold > 0 else None,
|
||||
is_unlocked=bool(threshold and total_spent_kopeks >= threshold),
|
||||
is_current=bool(promo_group and group.id == promo_group.id),
|
||||
)
|
||||
)
|
||||
|
||||
total_spent_label = _format_price_label(total_spent_kopeks) if total_spent_kopeks else None
|
||||
return total_spent_kopeks, levels, total_spent_label
|
||||
|
||||
|
||||
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
|
||||
username = user_data.get("username")
|
||||
if username:
|
||||
@@ -336,6 +393,11 @@ async def get_subscription_details(
|
||||
balance_currency = balance_currency.upper()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
total_spent_kopeks, auto_promo_levels, total_spent_label = await _load_auto_promo_levels(
|
||||
db,
|
||||
user,
|
||||
promo_group,
|
||||
)
|
||||
|
||||
response_user = MiniAppSubscriptionUser(
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -389,6 +451,9 @@ async def get_subscription_details(
|
||||
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
|
||||
if promo_group
|
||||
else None,
|
||||
auto_promo_levels=auto_promo_levels,
|
||||
total_spent_kopeks=total_spent_kopeks,
|
||||
total_spent_label=total_spent_label,
|
||||
subscription_type="trial" if subscription.is_trial else "paid",
|
||||
autopay_enabled=bool(subscription.autopay_enabled),
|
||||
branding=settings.get_miniapp_branding(),
|
||||
|
||||
@@ -41,6 +41,15 @@ class MiniAppPromoGroup(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class MiniAppAutoPromoLevel(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
threshold_kopeks: int
|
||||
threshold_label: Optional[str] = None
|
||||
is_unlocked: bool = False
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
@@ -90,6 +99,9 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
balance_currency: Optional[str] = None
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
auto_promo_levels: List[MiniAppAutoPromoLevel] = Field(default_factory=list)
|
||||
total_spent_kopeks: int = 0
|
||||
total_spent_label: Optional[str] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
|
||||
@@ -576,6 +576,118 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Promo Group */
|
||||
.promo-card .card-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.promo-group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.promo-group-value {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.promo-levels-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.promo-levels-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.promo-level-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(var(--primary-rgb), 0.04);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.promo-level-item.unlocked {
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.promo-level-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.promo-level-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promo-level-threshold {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.promo-level-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.promo-level-status.unlocked {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.promo-level-status-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.promo-level-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.promo-levels-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Balance Card */
|
||||
.balance-card {
|
||||
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05));
|
||||
@@ -1210,7 +1322,8 @@
|
||||
:root[data-theme="dark"] .server-item,
|
||||
:root[data-theme="dark"] .device-item,
|
||||
:root[data-theme="dark"] .app-card,
|
||||
:root[data-theme="dark"] .platform-btn {
|
||||
:root[data-theme="dark"] .platform-btn,
|
||||
:root[data-theme="dark"] .promo-level-item {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@@ -1248,7 +1361,8 @@
|
||||
:root[data-theme="dark"] .balance-card,
|
||||
:root[data-theme="dark"] .card.expandable,
|
||||
:root[data-theme="dark"] .language-select,
|
||||
:root[data-theme="dark"] .theme-toggle {
|
||||
:root[data-theme="dark"] .theme-toggle,
|
||||
:root[data-theme="dark"] .promo-card {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -1257,7 +1371,8 @@
|
||||
:root[data-theme="dark"] .stat-item,
|
||||
:root[data-theme="dark"] .server-item,
|
||||
:root[data-theme="dark"] .device-item,
|
||||
:root[data-theme="dark"] .app-card {
|
||||
:root[data-theme="dark"] .app-card,
|
||||
:root[data-theme="dark"] .promo-level-item {
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
@@ -1388,12 +1503,37 @@
|
||||
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
|
||||
<span class="info-value" id="autopayStatus">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promo Group Card -->
|
||||
<div class="card promo-card" id="promoCard">
|
||||
<div class="card-header">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span data-i18n="card.promo.title">Promo program</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="promo-group-row">
|
||||
<span class="info-label" data-i18n="info.promo_group">Promo group</span>
|
||||
<span class="promo-group-value" id="promoGroupValue">—</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="promo-levels-title" data-i18n="card.promo.levels_title">Auto-issued levels</div>
|
||||
<ul class="promo-levels-list" id="promoLevelsList"></ul>
|
||||
<div class="promo-levels-empty hidden" id="promoLevelsEmpty" data-i18n="card.promo.levels_empty">
|
||||
Auto-issued promo groups are not configured yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="connectBtn">
|
||||
<!-- Action Buttons -->
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" id="connectBtn">
|
||||
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
@@ -1663,6 +1803,14 @@
|
||||
'info.promo_group': 'Promo group',
|
||||
'info.device_limit': 'Device limit',
|
||||
'info.autopay': 'Auto-pay',
|
||||
'card.promo.title': 'Promo program',
|
||||
'card.promo.levels_title': 'Auto-issued levels',
|
||||
'card.promo.levels_empty': 'Auto-issued promo groups are not configured yet.',
|
||||
'promo.group.not_assigned': 'Not assigned',
|
||||
'promo.level.current': 'Current level',
|
||||
'promo.level.unlocked': 'Unlocked',
|
||||
'promo.level.locked': 'Locked',
|
||||
'promo.level.threshold': 'From {amount}',
|
||||
'button.connect.default': 'Connect to VPN',
|
||||
'button.connect.happ': 'Connect',
|
||||
'button.copy': 'Copy subscription link',
|
||||
@@ -1727,6 +1875,14 @@
|
||||
'info.promo_group': 'Промогруппа',
|
||||
'info.device_limit': 'Лимит устройств',
|
||||
'info.autopay': 'Автоплатеж',
|
||||
'card.promo.title': 'Промогруппа',
|
||||
'card.promo.levels_title': 'Автоматические уровни',
|
||||
'card.promo.levels_empty': 'Автовыдача промогрупп ещё не настроена.',
|
||||
'promo.group.not_assigned': 'Не назначена',
|
||||
'promo.level.current': 'Текущий уровень',
|
||||
'promo.level.unlocked': 'Получен',
|
||||
'promo.level.locked': 'Недоступен',
|
||||
'promo.level.threshold': 'От {amount}',
|
||||
'button.connect.default': 'Подключиться к VPN',
|
||||
'button.connect.happ': 'Подключиться',
|
||||
'button.copy': 'Скопировать ссылку подписки',
|
||||
@@ -2289,6 +2445,7 @@
|
||||
: autopayLabel;
|
||||
}
|
||||
|
||||
renderPromoSection();
|
||||
renderBalanceSection();
|
||||
renderTransactionHistory();
|
||||
renderServersList();
|
||||
@@ -2495,6 +2652,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatPriceFromKopeks(kopeks) {
|
||||
const numeric = typeof kopeks === 'number'
|
||||
? kopeks
|
||||
: Number.parseInt(kopeks ?? '0', 10);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return '—';
|
||||
}
|
||||
const currency = (userData?.balance_currency || 'RUB').toUpperCase();
|
||||
return formatCurrency(numeric / 100, currency);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
@@ -2535,6 +2703,110 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderPromoLevels() {
|
||||
const list = document.getElementById('promoLevelsList');
|
||||
const emptyState = document.getElementById('promoLevelsEmpty');
|
||||
if (!list || !emptyState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const levels = Array.isArray(userData?.auto_promo_levels)
|
||||
? userData.auto_promo_levels.filter(level => level && typeof level === 'object')
|
||||
: [];
|
||||
|
||||
const emptyLabelRaw = t('card.promo.levels_empty');
|
||||
const emptyLabel = emptyLabelRaw === 'card.promo.levels_empty'
|
||||
? 'Auto-issued promo groups are not configured yet.'
|
||||
: emptyLabelRaw;
|
||||
|
||||
if (!levels.length) {
|
||||
list.innerHTML = '';
|
||||
emptyState.textContent = emptyLabel;
|
||||
emptyState.classList.remove('hidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
const notAvailableRaw = t('values.not_available');
|
||||
const notAvailable = notAvailableRaw === 'values.not_available' ? '—' : notAvailableRaw;
|
||||
const currentLabelRaw = t('promo.level.current');
|
||||
const currentLabel = currentLabelRaw === 'promo.level.current' ? 'Current level' : currentLabelRaw;
|
||||
|
||||
const itemsHtml = levels.map(level => {
|
||||
const name = typeof level?.name === 'string' && level.name.trim().length
|
||||
? level.name.trim()
|
||||
: notAvailable;
|
||||
const amountLabelRaw = typeof level?.threshold_label === 'string' && level.threshold_label.trim().length
|
||||
? level.threshold_label.trim()
|
||||
: formatPriceFromKopeks(level?.threshold_kopeks);
|
||||
const amountLabel = amountLabelRaw && amountLabelRaw !== '—' ? amountLabelRaw : '';
|
||||
const thresholdTemplate = t('promo.level.threshold');
|
||||
const thresholdTextRaw = amountLabel
|
||||
? (thresholdTemplate && thresholdTemplate !== 'promo.level.threshold'
|
||||
? thresholdTemplate.replace('{amount}', amountLabel)
|
||||
: `From ${amountLabel}`)
|
||||
: notAvailable;
|
||||
const statusKey = level?.is_unlocked ? 'promo.level.unlocked' : 'promo.level.locked';
|
||||
const statusRaw = t(statusKey);
|
||||
const statusLabel = statusRaw === statusKey
|
||||
? (level?.is_unlocked ? 'Unlocked' : 'Locked')
|
||||
: statusRaw;
|
||||
const statusClass = level?.is_unlocked ? 'unlocked' : 'locked';
|
||||
const icon = level?.is_unlocked ? '✅' : '🔒';
|
||||
const badgeHtml = level?.is_current
|
||||
? `<span class="promo-level-badge">${escapeHtml(currentLabel)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<li class="promo-level-item ${statusClass}">
|
||||
<div class="promo-level-details">
|
||||
<div class="promo-level-name">
|
||||
${badgeHtml}
|
||||
<span>${escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="promo-level-threshold">${escapeHtml(thresholdTextRaw)}</div>
|
||||
</div>
|
||||
<div class="promo-level-status ${statusClass}">
|
||||
<span class="promo-level-status-icon">${icon}</span>
|
||||
<span>${escapeHtml(statusLabel)}</span>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
list.innerHTML = itemsHtml;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderPromoSection() {
|
||||
const card = document.getElementById('promoCard');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupElement = document.getElementById('promoGroupValue');
|
||||
const groupName = typeof userData?.promo_group?.name === 'string'
|
||||
? userData.promo_group.name
|
||||
: '';
|
||||
const notAvailableRaw = t('values.not_available');
|
||||
const notAvailable = notAvailableRaw === 'values.not_available' ? '—' : notAvailableRaw;
|
||||
|
||||
if (groupElement) {
|
||||
if (groupName) {
|
||||
groupElement.textContent = groupName;
|
||||
} else {
|
||||
const fallbackRaw = t('promo.group.not_assigned');
|
||||
const fallback = fallbackRaw === 'promo.group.not_assigned'
|
||||
? notAvailable
|
||||
: fallbackRaw;
|
||||
groupElement.textContent = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
renderPromoLevels();
|
||||
}
|
||||
|
||||
function renderBalanceSection() {
|
||||
const amountElement = document.getElementById('balanceAmount');
|
||||
if (!amountElement) {
|
||||
|
||||
Reference in New Issue
Block a user