diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py
index 1f308312..a545d665 100644
--- a/app/handlers/admin/promo_offers.py
+++ b/app/handlers/admin/promo_offers.py
@@ -11,6 +11,11 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.discount_offer import upsert_discount_offer
+from app.database.crud.server_squad import (
+ get_all_server_squads,
+ get_server_squad_by_id,
+ get_server_squad_by_uuid,
+)
from app.database.crud.promo_offer_template import (
ensure_default_templates,
get_promo_offer_template_by_id,
@@ -26,6 +31,9 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
+SQUADS_PAGE_LIMIT = 10
+
+
OFFER_TYPE_CONFIG = {
"test_access": {
"icon": "🧪",
@@ -280,12 +288,154 @@ async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: Async
@error_handler
async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split("_")[-1])
- texts = get_texts(db_user.language)
- prompt = texts.t(
- "ADMIN_PROMO_OFFER_PROMPT_SQUADS",
- "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
+ template = await get_promo_offer_template_by_id(db, template_id)
+ if not template:
+ await callback.answer("❌ Предложение не найдено", show_alert=True)
+ return
+
+ await state.update_data(
+ selected_promo_offer=template.id,
+ promo_edit_message_id=callback.message.message_id,
+ promo_edit_chat_id=callback.message.chat.id,
+ )
+
+ await _render_squad_selection(callback, template, db, db_user.language)
+ await callback.answer()
+
+
+async def _render_squad_selection(
+ callback: CallbackQuery,
+ template: PromoOfferTemplate,
+ db: AsyncSession,
+ language: str,
+ page: int = 1,
+):
+ texts = get_texts(language)
+
+ squads, total_count = await get_all_server_squads(
+ db,
+ available_only=False,
+ page=page,
+ limit=SQUADS_PAGE_LIMIT,
+ )
+
+ if total_count == 0:
+ await callback.message.edit_text(
+ texts.t("ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE", "❌ Доступные серверы не найдены."),
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_squad_back_{template.id}")]]
+ ),
+ )
+ return
+
+ selected_uuid = None
+ if template.test_squad_uuids:
+ selected_uuid = str(template.test_squad_uuids[0])
+
+ selected_server_name = None
+ if selected_uuid:
+ selected_server = next((srv for srv in squads if srv.squad_uuid == selected_uuid), None)
+ if not selected_server:
+ selected_server = await get_server_squad_by_uuid(db, selected_uuid)
+ if selected_server:
+ selected_server_name = selected_server.display_name
+
+ header = texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE", "🌍 Выберите сквад")
+ if selected_server_name:
+ current = texts.t(
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD",
+ "Текущий сквад: {name}",
+ ).format(name=selected_server_name)
+ elif selected_uuid:
+ current = texts.t(
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID",
+ "Текущий сквад: {uuid}",
+ ).format(uuid=selected_uuid)
+ else:
+ current = texts.t(
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY",
+ "Текущий сквад: не выбран",
+ )
+
+ hint = texts.t(
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT",
+ "Выберите сервер для тестового доступа из списка ниже.",
+ )
+
+ total_pages = (total_count + SQUADS_PAGE_LIMIT - 1) // SQUADS_PAGE_LIMIT or 1
+ page = max(1, min(page, total_pages))
+
+ lines = [header, "", current, "", hint]
+ if total_pages > 1:
+ lines.append(
+ texts.t(
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE",
+ "Страница {page}/{total}",
+ ).format(page=page, total=total_pages)
+ )
+
+ text = "\n".join(lines)
+
+ keyboard_rows: List[List[InlineKeyboardButton]] = []
+ for server in squads:
+ emoji = "✅" if server.squad_uuid == selected_uuid else ("⚪" if server.is_available else "🔒")
+ label = f"{emoji} {server.display_name}"
+ keyboard_rows.append([
+ InlineKeyboardButton(
+ text=label,
+ callback_data=f"promo_offer_select_squad_{template.id}_{server.id}_{page}",
+ )
+ ])
+
+ if total_pages > 1:
+ nav_row: List[InlineKeyboardButton] = []
+ if page > 1:
+ nav_row.append(
+ InlineKeyboardButton(
+ text="⬅️",
+ callback_data=f"promo_offer_squad_page_{template.id}_{page - 1}",
+ )
+ )
+ if page < total_pages:
+ nav_row.append(
+ InlineKeyboardButton(
+ text="➡️",
+ callback_data=f"promo_offer_squad_page_{template.id}_{page + 1}",
+ )
+ )
+ if nav_row:
+ keyboard_rows.append(nav_row)
+
+ action_row = [
+ InlineKeyboardButton(
+ text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR", "🗑 Очистить"),
+ callback_data=f"promo_offer_clear_squad_{template.id}_{page}",
+ ),
+ InlineKeyboardButton(
+ text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK", "↩️ Назад"),
+ callback_data=f"promo_offer_squad_back_{template.id}",
+ ),
+ ]
+ keyboard_rows.append(action_row)
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ parse_mode="HTML",
+ )
+
+
+async def _render_offer_details(
+ callback: CallbackQuery,
+ template: PromoOfferTemplate,
+ language: str,
+):
+ description = _describe_offer(template, language)
+ await callback.message.edit_text(
+ description,
+ reply_markup=_build_offer_detail_keyboard(template, language),
+ parse_mode="HTML",
)
- await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_squads)
async def _handle_edit_field(
@@ -488,8 +638,128 @@ async def process_edit_test_duration(message: Message, state: FSMContext, db: As
await _handle_edit_field(message, state, db, db_user, "test_duration_hours")
-async def process_edit_test_squads(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
- await _handle_edit_field(message, state, db, db_user, "test_squad_uuids")
+@admin_required
+@error_handler
+async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ try:
+ prefix = "promo_offer_squad_page_"
+ if not callback.data.startswith(prefix):
+ raise ValueError("invalid prefix")
+ payload = callback.data[len(prefix):]
+ template_id_str, page_str = payload.split("_", 1)
+ template_id = int(template_id_str)
+ page = int(page_str)
+ except (ValueError, AttributeError):
+ await callback.answer("❌ Некорректные данные", show_alert=True)
+ return
+
+ template = await get_promo_offer_template_by_id(db, template_id)
+ if not template:
+ await callback.answer("❌ Предложение не найдено", show_alert=True)
+ return
+
+ await state.update_data(selected_promo_offer=template.id)
+ await _render_squad_selection(callback, template, db, db_user.language, page=page)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ try:
+ prefix = "promo_offer_select_squad_"
+ if not callback.data.startswith(prefix):
+ raise ValueError("invalid prefix")
+ payload = callback.data[len(prefix):]
+ template_id_str, server_id_str, page_str = payload.split("_", 2)
+ template_id = int(template_id_str)
+ server_id = int(server_id_str)
+ page = int(page_str)
+ except (ValueError, AttributeError):
+ await callback.answer("❌ Некорректные данные", show_alert=True)
+ return
+
+ template = await get_promo_offer_template_by_id(db, template_id)
+ if not template:
+ await callback.answer("❌ Предложение не найдено", show_alert=True)
+ return
+
+ server = await get_server_squad_by_id(db, server_id)
+ if not server:
+ await callback.answer(
+ get_texts(db_user.language).t(
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND",
+ "❌ Сервер не найден",
+ ),
+ show_alert=True,
+ )
+ return
+
+ await update_promo_offer_template(db, template, test_squad_uuids=[server.squad_uuid])
+ updated = await get_promo_offer_template_by_id(db, template.id)
+ if updated:
+ await state.update_data(selected_promo_offer=updated.id)
+
+ texts = get_texts(db_user.language)
+ await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED", "✅ Сквад обновлён"))
+
+ if updated:
+ await _render_offer_details(callback, updated, db_user.language)
+ else:
+ await _render_squad_selection(callback, template, db, db_user.language, page=page)
+
+
+@admin_required
+@error_handler
+async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ try:
+ prefix = "promo_offer_clear_squad_"
+ if not callback.data.startswith(prefix):
+ raise ValueError("invalid prefix")
+ payload = callback.data[len(prefix):]
+ template_id_str, page_str = payload.split("_", 1)
+ template_id = int(template_id_str)
+ page = int(page_str)
+ except (ValueError, AttributeError):
+ await callback.answer("❌ Некорректные данные", show_alert=True)
+ return
+
+ template = await get_promo_offer_template_by_id(db, template_id)
+ if not template:
+ await callback.answer("❌ Предложение не найдено", show_alert=True)
+ return
+
+ await update_promo_offer_template(db, template, test_squad_uuids=[])
+ updated = await get_promo_offer_template_by_id(db, template.id)
+ if updated:
+ await state.update_data(selected_promo_offer=updated.id)
+
+ texts = get_texts(db_user.language)
+ await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED", "✅ Сквад очищен"))
+
+ if updated:
+ await _render_squad_selection(callback, updated, db, db_user.language, page=page)
+ else:
+ await _render_squad_selection(callback, template, db, db_user.language, page=page)
+
+
+@admin_required
+@error_handler
+async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ try:
+ template_id = int(callback.data.split("_")[-1])
+ except (ValueError, AttributeError):
+ await callback.answer("❌ Некорректные данные", show_alert=True)
+ return
+
+ template = await get_promo_offer_template_by_id(db, template_id)
+ if not template:
+ await callback.answer("❌ Предложение не найдено", show_alert=True)
+ return
+
+ await state.update_data(selected_promo_offer=template.id)
+ await _render_offer_details(callback, template, db_user.language)
+ await callback.answer()
def register_handlers(dp: Dispatcher):
@@ -500,6 +770,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_"))
dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_"))
dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_"))
+ dp.callback_query.register(paginate_squad_selection, F.data.startswith("promo_offer_squad_page_"))
+ dp.callback_query.register(select_squad_for_template, F.data.startswith("promo_offer_select_squad_"))
+ dp.callback_query.register(clear_squad_for_template, F.data.startswith("promo_offer_clear_squad_"))
+ dp.callback_query.register(back_to_offer_from_squads, F.data.startswith("promo_offer_squad_back_"))
dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_"))
dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_"))
dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_"))
@@ -509,4 +783,3 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours)
dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount)
dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration)
- dp.message.register(process_edit_test_squads, AdminStates.editing_promo_offer_squads)
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 2e5280bd..230340f5 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -5195,6 +5195,11 @@ async def claim_discount_offer(
"TEST_ACCESS_NO_SQUADS",
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
)
+ elif error_code == "remnawave_sync_failed":
+ error_message = texts.get(
+ "TEST_ACCESS_REMNAWAVE_ERROR",
+ "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
+ )
else:
error_message = texts.get(
"TEST_ACCESS_UNKNOWN_ERROR",
@@ -5213,7 +5218,17 @@ async def claim_discount_offer(
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
await callback.answer(popup_text, show_alert=True)
- await callback.message.answer(success_message)
+ back_keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ]
+ ]
+ )
+ await callback.message.answer(success_message, reply_markup=back_keyboard)
return
discount_percent = int(offer.discount_percent or 0)
diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py
index 4dd2c18d..c4427562 100644
--- a/app/services/promo_offer_service.py
+++ b/app/services/promo_offer_service.py
@@ -34,10 +34,21 @@ class PromoOfferService:
return False, None, None, "subscription_missing"
payload = offer.extra_data or {}
- squad_uuids: Sequence[str] = payload.get("test_squad_uuids") or payload.get("squads") or []
+ raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
+ if isinstance(raw_squads, str):
+ candidates = [raw_squads]
+ else:
+ try:
+ candidates = list(raw_squads)
+ except TypeError:
+ candidates = []
+
+ squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
if not squad_uuids:
return False, None, None, "squads_missing"
+ squad_uuids = list(dict.fromkeys(squad_uuids))
+
try:
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
except (TypeError, ValueError):
@@ -50,7 +61,9 @@ class PromoOfferService:
expires_at = now + timedelta(hours=duration_hours)
connected = set(subscription.connected_squads or [])
+ original_connected = set(connected)
newly_added: List[str] = []
+ changes_made = False
for squad_uuid in squad_uuids:
normalized_uuid = str(squad_uuid)
@@ -64,13 +77,16 @@ class PromoOfferService:
)
existing_access = existing_result.scalars().first()
if existing_access and existing_access.is_active:
- existing_access.expires_at = expires_at
+ if existing_access.expires_at < expires_at:
+ existing_access.expires_at = expires_at
+ changes_made = True
continue
was_already_connected = normalized_uuid in connected
if not was_already_connected:
connected.add(normalized_uuid)
newly_added.append(normalized_uuid)
+ changes_made = True
access_entry = SubscriptionTemporaryAccess(
subscription_id=subscription.id,
@@ -81,16 +97,33 @@ class PromoOfferService:
was_already_connected=was_already_connected,
)
db.add(access_entry)
+ changes_made = True
+
+ connected_changed = connected != original_connected
if newly_added:
subscription.connected_squads = list(connected)
subscription.updated_at = now
+ changes_made = True
- await db.commit()
- await db.refresh(subscription)
+ if connected_changed:
+ remnawave_user = await self.subscription_service.update_remnawave_user(
+ db,
+ subscription,
+ )
+ if remnawave_user is None:
+ await db.rollback()
+ await db.refresh(subscription)
+ logger.error(
+ "Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
+ subscription.id,
+ )
+ return False, None, None, "remnawave_sync_failed"
- if newly_added:
- await self.subscription_service.update_remnawave_user(db, subscription)
+ await db.refresh(subscription)
+ elif changes_made:
+ await db.commit()
+ await db.refresh(subscription)
return True, newly_added, expires_at, "ok"
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index 012bf6f8..74f0877a 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -78,31 +78,56 @@ def get_traffic_reset_strategy():
class SubscriptionService:
def __init__(self):
+ self._config_error: Optional[str] = None
+ self.api: Optional[RemnaWaveAPI] = None
+ self._last_config_signature: Optional[Tuple[str, ...]] = None
+
+ self._refresh_configuration()
+
+ def _refresh_configuration(self) -> None:
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
+ secret_key = (auth_params.get("secret_key") or "").strip() or None
+ username = (auth_params.get("username") or "").strip() or None
+ password = (auth_params.get("password") or "").strip() or None
+ auth_type = (auth_params.get("auth_type") or "").strip() or None
- self._config_error: Optional[str] = None
+ config_signature = (
+ base_url,
+ api_key,
+ secret_key or "",
+ username or "",
+ password or "",
+ auth_type or "",
+ )
+
+ if config_signature == self._last_config_signature:
+ return
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
+ self.api = None
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
+ self.api = None
+ else:
+ self._config_error = None
+ self.api = RemnaWaveAPI(
+ base_url=base_url,
+ api_key=api_key,
+ secret_key=secret_key,
+ username=username,
+ password=password,
+ )
if self._config_error:
logger.warning(
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
self._config_error
)
- self.api = None
- else:
- self.api = RemnaWaveAPI(
- base_url=base_url,
- api_key=api_key,
- secret_key=auth_params.get("secret_key"),
- username=auth_params.get("username"),
- password=auth_params.get("password")
- )
+
+ self._last_config_signature = config_signature
@property
def is_configured(self) -> bool:
@@ -113,6 +138,7 @@ class SubscriptionService:
return self._config_error
def _ensure_configured(self) -> None:
+ self._refresh_configuration()
if not self.api or not self.is_configured:
raise RemnaWaveAPIError(
self._config_error or "RemnaWave API не настроен"
diff --git a/locales/en.json b/locales/en.json
index 817b8091..edcb8662 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -536,6 +536,7 @@
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
+ "TEST_ACCESS_REMNAWAVE_ERROR": "❌ Failed to connect the servers. Please try again later or contact support.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
@@ -584,7 +585,18 @@
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
- "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Select a squad",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found",
+ "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.",
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
diff --git a/locales/ru.json b/locales/ru.json
index be4b4c7b..1ccfdd96 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -536,6 +536,7 @@
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
+ "TEST_ACCESS_REMNAWAVE_ERROR": "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
@@ -584,7 +585,18 @@
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
- "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Выберите сквад",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}",
+ "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен",
+ "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден",
+ "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.",
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",