mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-05 05:13:21 +00:00
feat(admin): manage user promo offers
This commit is contained in:
@@ -1,39 +1,60 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard,
|
||||
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.discount_offer import upsert_discount_offer
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.database.crud.promo_offer_template import (
|
||||
ensure_default_templates,
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
)
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
get_server_squad_by_id,
|
||||
get_server_ids_by_uuids,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
TransactionType,
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard,
|
||||
get_admin_users_filters_keyboard,
|
||||
get_admin_users_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
get_user_management_keyboard,
|
||||
get_user_promo_group_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_service import UserService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_timer_line,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -997,6 +1018,12 @@ async def _render_user_subscription_overview(
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
if subscription is not None:
|
||||
try:
|
||||
setattr(user, "subscription", subscription)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
@@ -1448,6 +1475,44 @@ async def show_user_management(
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
promo_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
promo_source = getattr(user, "promo_offer_discount_source", None)
|
||||
|
||||
if promo_percent > 0:
|
||||
expires_text = (
|
||||
format_datetime(promo_expires_at)
|
||||
if promo_expires_at
|
||||
else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞")
|
||||
)
|
||||
source_text = (
|
||||
promo_source
|
||||
if promo_source
|
||||
else texts.get("ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN", "—")
|
||||
)
|
||||
promo_section = texts.get(
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE",
|
||||
"🎯 <b>Активное промо:</b> {percent}% (источник: {source})\nДействует до: {expires_at}",
|
||||
).format(
|
||||
percent=promo_percent,
|
||||
source=source_text,
|
||||
expires_at=expires_text,
|
||||
)
|
||||
try:
|
||||
timer_line = await build_promo_offer_timer_line(db, user, texts)
|
||||
except Exception:
|
||||
timer_line = None
|
||||
if timer_line:
|
||||
promo_section += f"\n{timer_line}"
|
||||
sections.append(promo_section)
|
||||
else:
|
||||
sections.append(
|
||||
texts.get(
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE",
|
||||
"🎯 <b>Активное промо:</b> отсутствует",
|
||||
)
|
||||
)
|
||||
|
||||
text = "\n\n".join(sections)
|
||||
|
||||
# Проверяем состояние, чтобы определить, откуда пришел пользователь
|
||||
@@ -1485,6 +1550,76 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
PROMO_TEMPLATE_ICONS = {
|
||||
"test_access": "🧪",
|
||||
"extend_discount": "💎",
|
||||
"purchase_discount": "🎯",
|
||||
}
|
||||
|
||||
PROMO_TEMPLATE_EFFECT_TYPES = {
|
||||
"test_access": "test_access",
|
||||
"extend_discount": "percent_discount",
|
||||
"purchase_discount": "percent_discount",
|
||||
}
|
||||
|
||||
|
||||
def _build_user_template_button_text(template) -> str:
|
||||
icon = PROMO_TEMPLATE_ICONS.get(template.offer_type, "📨")
|
||||
suffix = ""
|
||||
try:
|
||||
percent = int(template.discount_percent or 0)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0
|
||||
if template.offer_type != "test_access" and percent > 0:
|
||||
suffix = f" ({percent}%)"
|
||||
return f"{icon} {template.name}{suffix}"
|
||||
|
||||
|
||||
def _render_promo_template_text(template, language: str, *, server_name: Optional[str] = None) -> str:
|
||||
replacements = {
|
||||
"discount_percent": template.discount_percent,
|
||||
"valid_hours": template.valid_hours,
|
||||
"test_duration_hours": template.test_duration_hours or 0,
|
||||
"active_discount_hours": template.active_discount_hours or template.valid_hours,
|
||||
}
|
||||
|
||||
if server_name is not None:
|
||||
replacements.setdefault("server_name", server_name)
|
||||
else:
|
||||
replacements.setdefault("server_name", "???")
|
||||
|
||||
try:
|
||||
return template.message_text.format(**replacements)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Не удалось форматировать текст промо-предложения %s для языка %s",
|
||||
template.id,
|
||||
language,
|
||||
)
|
||||
return template.message_text
|
||||
|
||||
|
||||
async def _resolve_template_squad_info(
|
||||
db: AsyncSession,
|
||||
template,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
if template.offer_type != "test_access":
|
||||
return None, None
|
||||
|
||||
squad_uuids = template.test_squad_uuids or []
|
||||
if not squad_uuids:
|
||||
return None, None
|
||||
|
||||
squad_uuid = str(squad_uuids[0])
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
server_name = server.display_name if server else None
|
||||
return squad_uuid, server_name
|
||||
|
||||
|
||||
def _get_template_effect_type(template) -> str:
|
||||
return PROMO_TEMPLATE_EFFECT_TYPES.get(template.offer_type, "percent_discount")
|
||||
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
@@ -1615,6 +1750,313 @@ async def set_user_promo_group(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_promo_templates(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
user_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, AttributeError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
target_user: User = profile["user"]
|
||||
subscription: Optional[Subscription] = profile.get("subscription")
|
||||
if subscription is not None:
|
||||
try:
|
||||
setattr(target_user, "subscription", subscription)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await ensure_default_templates(db, created_by=db_user.id)
|
||||
templates = await list_promo_offer_templates(db)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
active_templates = [template for template in templates if getattr(template, "is_active", True)]
|
||||
|
||||
if not active_templates:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_USER_PROMO_TEMPLATES_EMPTY", "Нет активных шаблонов промо-предложений."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
promo_percent = get_user_active_promo_discount_percent(target_user)
|
||||
promo_expires_at = getattr(target_user, "promo_offer_discount_expires_at", None)
|
||||
expires_text = (
|
||||
format_datetime(promo_expires_at)
|
||||
if promo_expires_at
|
||||
else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞")
|
||||
)
|
||||
|
||||
message_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATES_TITLE",
|
||||
"🎯 <b>Промо-предложения для {name}</b>",
|
||||
).format(name=target_user.full_name),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATES_HINT",
|
||||
"Выберите шаблон, чтобы отправить пользователю промо-предложение.",
|
||||
),
|
||||
]
|
||||
|
||||
if promo_percent > 0:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO",
|
||||
"Активная скидка: {percent}% (до {expires_at})",
|
||||
).format(percent=promo_percent, expires_at=expires_text),
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_build_user_template_button_text(template),
|
||||
callback_data=f"admin_user_promo_send_{target_user.id}_{template.id}",
|
||||
)
|
||||
]
|
||||
for template in active_templates
|
||||
]
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{target_user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
await state.set_state(None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_user_promo_template(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
prefix = "admin_user_promo_send_"
|
||||
if not callback.data.startswith(prefix):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
payload = callback.data[len(prefix):]
|
||||
try:
|
||||
user_part, template_part = payload.split("_", 1)
|
||||
user_id = int(user_part)
|
||||
template_id = int(template_part)
|
||||
except (ValueError, AttributeError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
target_user: User = profile["user"]
|
||||
subscription: Optional[Subscription] = profile.get("subscription")
|
||||
if subscription is not None:
|
||||
try:
|
||||
setattr(target_user, "subscription", subscription)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template or not getattr(template, "is_active", True):
|
||||
await callback.answer("❌ Шаблон недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
squad_uuid, squad_name = await _resolve_template_squad_info(db, template)
|
||||
|
||||
if template.offer_type == "test_access" and squad_uuid:
|
||||
connected = set(subscription.connected_squads or []) if subscription else set()
|
||||
if squad_uuid in connected:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS",
|
||||
"У пользователя уже есть доступ к этому серверу.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
effect_type = _get_template_effect_type(template)
|
||||
|
||||
try:
|
||||
offer_record = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=target_user.id,
|
||||
subscription_id=subscription.id if subscription else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to upsert discount offer for template %s and user %s: %s",
|
||||
template_id,
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR",
|
||||
"Не удалось подготовить промо-предложение.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_language = target_user.language or db_user.language
|
||||
user_texts = get_texts(user_language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
message_text = _render_promo_template_text(
|
||||
template,
|
||||
user_language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=target_user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED",
|
||||
"Пользователь заблокировал бота. Сообщение не отправлено.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning(
|
||||
"Telegram API error while sending promo template %s to %s: %s",
|
||||
template_id,
|
||||
target_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED",
|
||||
"Не удалось отправить сообщение пользователю.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Unexpected error while sending promo template %s to %s: %s",
|
||||
template_id,
|
||||
target_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR",
|
||||
"Не удалось отправить промо-предложение пользователю.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
success_text = texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS",
|
||||
"✅ Промо-предложение «{name}» отправлено пользователю {user}.",
|
||||
).format(name=template.name, user=target_user.full_name)
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN",
|
||||
"📨 Отправить другое",
|
||||
),
|
||||
callback_data=f"admin_user_promo_templates_{target_user.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{target_user.id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await state.set_state(None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_DONE",
|
||||
"Промо-предложение отправлено.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -3969,6 +4411,16 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_promo_templates,
|
||||
F.data.startswith("admin_user_promo_templates_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
send_user_promo_template,
|
||||
F.data.startswith("admin_user_promo_send_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
|
||||
@@ -724,6 +724,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("ADMIN_USER_PROMO_TEMPLATES_BUTTON", "🎯 Промо-предложения"),
|
||||
callback_data=f"admin_user_promo_templates_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
"ATTACHMENTS_SENT": "✅ Attachments sent.",
|
||||
"DELETE_MESSAGE": "🗑 Delete",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Promo offers",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
@@ -784,6 +785,20 @@
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Subscription:</b> None",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 <b>Active promo:</b> {percent}% (source: {source})\nValid until: {expires_at}",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 <b>Active promo:</b> none",
|
||||
"ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "unknown",
|
||||
"ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "no expiry",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 <b>Promo offers for {name}</b>",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_HINT": "Choose a template to send a promo offer to the user.",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Active discount: {percent}% (until {expires_at})",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_EMPTY": "No active promo offer templates.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "The user already has access to this server.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Failed to send the promo offer to the user.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Failed to deliver the message to the user.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Promo offer “{name}” sent to {user}.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Send another",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Promo offer sent.",
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Промо-предложения",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
@@ -784,6 +785,20 @@
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 <b>Активное промо:</b> {percent}% (источник: {source})\nДействует до: {expires_at}",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 <b>Активное промо:</b> отсутствует",
|
||||
"ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "неизвестно",
|
||||
"ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "без ограничения",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 <b>Промо-предложения для {name}</b>",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_HINT": "Выберите шаблон, чтобы отправить промо-предложение пользователю.",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Активная скидка: {percent}% (до {expires_at})",
|
||||
"ADMIN_USER_PROMO_TEMPLATES_EMPTY": "Нет активных шаблонов промо-предложений.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "У пользователя уже есть доступ к этому серверу.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Не удалось отправить промо-предложение пользователю.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Не удалось отправить сообщение пользователю.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Промо-предложение «{name}» отправлено пользователю {user}.",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Отправить другое",
|
||||
"ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Промо-предложение отправлено.",
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
|
||||
|
||||
Reference in New Issue
Block a user