diff --git a/app/bot.py b/app/bot.py index 997b6ecb..d5815987 100644 --- a/app/bot.py +++ b/app/bot.py @@ -44,6 +44,7 @@ from app.handlers.admin import ( bot_configuration as admin_bot_configuration, pricing as admin_pricing, privacy_policy as admin_privacy_policy, + public_offer as admin_public_offer, ) from app.handlers.stars_payments import register_stars_handlers @@ -151,6 +152,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_bot_configuration.register_handlers(dp) admin_pricing.register_handlers(dp) admin_privacy_policy.register_handlers(dp) + admin_public_offer.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/database/crud/public_offer.py b/app/database/crud/public_offer.py new file mode 100644 index 00000000..78d5716e --- /dev/null +++ b/app/database/crud/public_offer.py @@ -0,0 +1,79 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import PublicOffer + +logger = logging.getLogger(__name__) + + +async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]: + result = await db.execute( + select(PublicOffer).where(PublicOffer.language == language) + ) + return result.scalar_one_or_none() + + +async def upsert_public_offer( + db: AsyncSession, + language: str, + content: str, + *, + enable_if_new: bool = True, +) -> PublicOffer: + offer = await get_public_offer(db, language) + + if offer: + offer.content = content or "" + offer.updated_at = datetime.utcnow() + else: + offer = PublicOffer( + language=language, + content=content or "", + is_enabled=True if enable_if_new else False, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + + logger.info( + "✅ Публичная оферта для языка %s обновлена (ID: %s)", + language, + offer.id, + ) + + return offer + + +async def set_public_offer_enabled( + db: AsyncSession, + language: str, + enabled: bool, +) -> PublicOffer: + offer = await get_public_offer(db, language) + + if offer: + offer.is_enabled = bool(enabled) + offer.updated_at = datetime.utcnow() + else: + offer = PublicOffer( + language=language, + content="", + is_enabled=bool(enabled), + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + + logger.info( + "✅ Статус публичной оферты для языка %s обновлен: %s", + language, + "enabled" if offer.is_enabled else "disabled", + ) + + return offer diff --git a/app/database/models.py b/app/database/models.py index f48f2f47..3536fecb 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -771,6 +771,17 @@ class PrivacyPolicy(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) +class PublicOffer(Base): + __tablename__ = "public_offers" + + id = Column(Integer, primary_key=True, index=True) + language = Column(String(10), nullable=False, unique=True) + content = Column(Text, nullable=False) + is_enabled = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + class SystemSetting(Base): __tablename__ = "system_settings" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index a29a748f..791d5d4d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -2553,6 +2553,59 @@ async def create_privacy_policies_table() -> bool: return False +async def create_public_offers_table() -> bool: + table_exists = await check_table_exists("public_offers") + if table_exists: + logger.info("ℹ️ Таблица public_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE public_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE public_offers ( + id SERIAL PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + """ + else: + create_sql = """ + CREATE TABLE public_offers ( + id INT AUTO_INCREMENT PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB; + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица public_offers создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы public_offers: {error}") + return False + + async def ensure_default_web_api_token() -> bool: default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip() if not default_token: @@ -2642,6 +2695,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей privacy_policies") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===") + public_offers_ready = await create_public_offers_table() + if public_offers_ready: + logger.info("✅ Таблица public_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей public_offers") + logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===") default_token_ready = await ensure_default_web_api_token() if default_token_ready: @@ -2929,6 +2989,7 @@ async def check_migration_status(): "promo_groups_table": False, "server_promo_groups_table": False, "privacy_policies_table": False, + "public_offers_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, @@ -2954,6 +3015,7 @@ async def check_migration_status(): status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["privacy_policies_table"] = await check_table_exists('privacy_policies') + status["public_offers_table"] = await check_table_exists('public_offers') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') @@ -3004,6 +3066,7 @@ async def check_migration_status(): "user_messages_table": "Таблица пользовательских сообщений", "welcome_texts_table": "Таблица приветственных текстов", "privacy_policies_table": "Таблица политик конфиденциальности", + "public_offers_table": "Таблица публичных оферт", "welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts", "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", diff --git a/app/handlers/admin/public_offer.py b/app/handlers/admin/public_offer.py new file mode 100644 index 00000000..141e7de5 --- /dev/null +++ b/app/handlers/admin/public_offer.py @@ -0,0 +1,530 @@ +import html +import logging +from datetime import datetime + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.localization.texts import get_texts +from app.services.public_offer_service import PublicOfferService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.validators import validate_html_tags, get_html_help_text + +logger = logging.getLogger(__name__) + + +def _format_timestamp(value: datetime | None) -> str: + if not value: + return "" + try: + return value.strftime("%d.%m.%Y %H:%M") + except Exception: + return "" + + +async def _build_overview( + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + offer = await PublicOfferService.get_offer( + db, + db_user.language, + fallback=False, + ) + + normalized_language = PublicOfferService.normalize_language(db_user.language) + has_content = bool(offer and offer.content and offer.content.strip()) + + description = texts.t( + "ADMIN_PUBLIC_OFFER_DESCRIPTION", + "Публичная оферта отображается в разделе «Инфо».", + ) + + status_text = texts.t( + "ADMIN_PUBLIC_OFFER_STATUS_DISABLED", + "⚠️ Показ оферты выключен или текст отсутствует.", + ) + if offer and offer.is_enabled and has_content: + status_text = texts.t( + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED", + "✅ Оферта активна и показывается пользователям.", + ) + elif offer and offer.is_enabled: + status_text = texts.t( + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY", + "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.", + ) + + updated_at = _format_timestamp(getattr(offer, "updated_at", None)) + updated_block = "" + if updated_at: + updated_block = texts.t( + "ADMIN_PUBLIC_OFFER_UPDATED_AT", + "Последнее обновление: {timestamp}", + ).format(timestamp=updated_at) + + preview_block = texts.t( + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY", + "Текст ещё не задан.", + ) + if has_content: + preview_title = texts.t( + "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE", + "Превью текста:", + ) + preview_raw = offer.content.strip() + preview_trimmed = preview_raw[:400] + if len(preview_raw) > 400: + preview_trimmed += "..." + preview_block = ( + f"{preview_title}\n" + f"{html.escape(preview_trimmed)}" + ) + + language_block = texts.t( + "ADMIN_PUBLIC_OFFER_LANGUAGE", + "Язык: {lang}", + ).format(lang=normalized_language) + + header = texts.t( + "ADMIN_PUBLIC_OFFER_HEADER", + "📄 Публичная оферта", + ) + actions_prompt = texts.t( + "ADMIN_PUBLIC_OFFER_ACTION_PROMPT", + "Выберите действие:", + ) + + message_parts = [ + header, + description, + language_block, + status_text, + ] + + if updated_block: + message_parts.append(updated_block) + + message_parts.append(preview_block) + message_parts.append(actions_prompt) + + overview_text = "\n\n".join(part for part in message_parts if part) + + buttons: list[list[types.InlineKeyboardButton]] = [] + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON", + "✏️ Изменить текст", + ), + callback_data="admin_public_offer_edit", + ) + ]) + + if has_content: + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_VIEW_BUTTON", + "👀 Просмотреть текущий текст", + ), + callback_data="admin_public_offer_view", + ) + ]) + + toggle_text = texts.t( + "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON", + "✅ Включить показ", + ) + if offer and offer.is_enabled: + toggle_text = texts.t( + "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON", + "🚫 Отключить показ", + ) + + buttons.append([ + types.InlineKeyboardButton( + text=toggle_text, + callback_data="admin_public_offer_toggle", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_HTML_HELP", + "ℹ️ HTML помощь", + ), + callback_data="admin_public_offer_help", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_settings", + ) + ]) + + return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer + + +@admin_required +@error_handler +async def show_public_offer_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + overview_text, markup, _ = await _build_overview(db_user, db) + + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_public_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language) + logger.info( + "Админ %s переключил показ публичной оферты: %s", + db_user.telegram_id, + "enabled" if updated_offer.is_enabled else "disabled", + ) + status_message = ( + texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена") + if updated_offer.is_enabled + else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена") + ) + + overview_text, markup, _ = await _build_overview(db_user, db) + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer(status_message) + + +@admin_required +@error_handler +async def start_edit_public_offer( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + offer = await PublicOfferService.get_offer( + db, + db_user.language, + fallback=False, + ) + + current_preview = "" + if offer and offer.content: + preview = offer.content.strip()[:400] + if len(offer.content.strip()) > 400: + preview += "..." + current_preview = ( + texts.t( + "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW", + "Текущий текст (превью):", + ) + + f"\n{html.escape(preview)}\n\n" + ) + + prompt = texts.t( + "ADMIN_PUBLIC_OFFER_EDIT_PROMPT", + "Отправьте новый текст публичной оферты. Допускается HTML-разметка.", + ) + + hint = texts.t( + "ADMIN_PUBLIC_OFFER_EDIT_HINT", + "Используйте /html_help для справки по тегам.", + ) + + message_text = ( + f"📝 {texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}\n\n" + f"{current_preview}{prompt}\n\n{hint}" + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_HTML_HELP", + "ℹ️ HTML помощь", + ), + callback_data="admin_public_offer_help", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"), + callback_data="admin_public_offer_cancel", + ) + ], + ] + ) + + await callback.message.edit_text(message_text, reply_markup=keyboard) + await state.set_state(AdminStates.editing_public_offer) + await callback.answer() + + +@admin_required +@error_handler +async def cancel_edit_public_offer( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + overview_text, markup, _ = await _build_overview(db_user, db) + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer( + get_texts(db_user.language).t( + "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED", + "Редактирование оферты отменено.", + ) + ) + + +@admin_required +@error_handler +async def process_public_offer_edit( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + new_text = message.text or "" + + if len(new_text) > 4000: + await message.answer( + texts.t( + "ADMIN_PUBLIC_OFFER_TOO_LONG", + "❌ Текст оферты слишком длинный. Максимум 4000 символов.", + ) + ) + return + + is_valid, error_message = validate_html_tags(new_text) + if not is_valid: + await message.answer( + texts.t( + "ADMIN_PUBLIC_OFFER_HTML_ERROR", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + ) + return + + await PublicOfferService.save_offer(db, db_user.language, new_text) + logger.info( + "Админ %s обновил текст публичной оферты (%d символов)", + db_user.telegram_id, + len(new_text), + ) + await state.clear() + + success_text = texts.t( + "ADMIN_PUBLIC_OFFER_SAVED", + "✅ Публичная оферта обновлена.", + ) + + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_BACK_BUTTON", + "⬅️ К настройкам оферты", + ), + callback_data="admin_public_offer", + ) + ] + ] + ) + + await message.answer(success_text, reply_markup=reply_markup) + + +@admin_required +@error_handler +async def view_public_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + offer = await PublicOfferService.get_offer( + db, + db_user.language, + fallback=False, + ) + + if not offer or not offer.content or not offer.content.strip(): + await callback.answer( + texts.t( + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT", + "Текст оферты пока не задан.", + ), + show_alert=True, + ) + return + + content = offer.content.strip() + max_length = 3800 + pages = PublicOfferService.split_content_into_pages( + content, + max_length=max_length, + ) + + if not pages: + await callback.answer( + texts.t( + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT", + "Текст оферты пока не задан.", + ), + show_alert=True, + ) + return + + preview = pages[0] + truncated = len(pages) > 1 + + header = texts.t( + "ADMIN_PUBLIC_OFFER_VIEW_TITLE", + "👀 Текущий текст оферты", + ) + + note = "" + if truncated: + note = texts.t( + "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED", + "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_BACK_BUTTON", + "⬅️ К настройкам оферты", + ), + callback_data="admin_public_offer", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON", + "✏️ Изменить текст", + ), + callback_data="admin_public_offer_edit", + ) + ], + ] + ) + + await callback.message.edit_text( + f"{header}\n\n{preview}{note}", + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_public_offer_html_help( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + help_text = get_html_help_text() + + current_state = await state.get_state() + + buttons: list[list[types.InlineKeyboardButton]] = [] + + if current_state == AdminStates.editing_public_offer.state: + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT", + "⬅️ Назад к редактированию", + ), + callback_data="admin_public_offer_edit", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PUBLIC_OFFER_BACK_BUTTON", + "⬅️ К настройкам оферты", + ), + callback_data="admin_public_offer", + ) + ]) + + await callback.message.edit_text( + help_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + show_public_offer_management, + F.data == "admin_public_offer", + ) + dp.callback_query.register( + toggle_public_offer, + F.data == "admin_public_offer_toggle", + ) + dp.callback_query.register( + start_edit_public_offer, + F.data == "admin_public_offer_edit", + ) + dp.callback_query.register( + cancel_edit_public_offer, + F.data == "admin_public_offer_cancel", + ) + dp.callback_query.register( + view_public_offer, + F.data == "admin_public_offer_view", + ) + dp.callback_query.register( + show_public_offer_html_help, + F.data == "admin_public_offer_help", + ) + + dp.message.register( + process_public_offer_edit, + AdminStates.editing_public_offer, + ) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index fab18d5b..a15d42c2 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -26,6 +26,7 @@ from app.utils.promo_offer import ( build_test_access_hint, ) from app.services.privacy_policy_service import PrivacyPolicyService +from app.services.public_offer_service import PublicOfferService logger = logging.getLogger(__name__) @@ -112,6 +113,7 @@ async def show_info_menu( caption = f"{header}\n\n{prompt}" if prompt else header privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language) + public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language) await edit_or_answer_photo( callback=callback, @@ -119,6 +121,7 @@ async def show_info_menu( keyboard=get_info_menu_keyboard( language=db_user.language, show_privacy_policy=privacy_enabled, + show_public_offer=public_offer_enabled, ), parse_mode="HTML", ) @@ -232,6 +235,113 @@ async def show_privacy_policy( await callback.answer() +async def show_public_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_page = 1 + if callback.data and ":" in callback.data: + try: + raw_page = int(callback.data.split(":", 1)[1]) + except ValueError: + raw_page = 1 + + if raw_page < 1: + raw_page = 1 + + offer = await PublicOfferService.get_active_offer(db, db_user.language) + + if not offer: + await callback.answer( + texts.t( + "PUBLIC_OFFER_NOT_AVAILABLE", + "Публичная оферта временно недоступна.", + ), + show_alert=True, + ) + return + + pages = PublicOfferService.split_content_into_pages(offer.content) + + if not pages: + await callback.answer( + texts.t( + "PUBLIC_OFFER_EMPTY_ALERT", + "Публичная оферта ещё не заполнена.", + ), + show_alert=True, + ) + return + + total_pages = len(pages) + current_page = raw_page if raw_page <= total_pages else total_pages + + header = texts.t( + "PUBLIC_OFFER_HEADER", + "📄 Публичная оферта", + ) + body = pages[current_page - 1] + + footer_template = texts.t( + "PUBLIC_OFFER_PAGE_INFO", + "Страница {current} из {total}", + ) + footer = "" + if total_pages > 1 and footer_template: + try: + footer = footer_template.format(current=current_page, total=total_pages) + except Exception: + footer = f"{current_page}/{total_pages}" + + message_text = header + if body: + message_text += f"\n\n{body}" + if footer: + message_text += f"\n\n{footer}" + + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] + + if total_pages > 1: + nav_row: list[types.InlineKeyboardButton] = [] + if current_page > 1: + nav_row.append( + types.InlineKeyboardButton( + text=texts.t("PAGINATION_PREV", "⬅️"), + callback_data=f"menu_public_offer:{current_page - 1}", + ) + ) + + nav_row.append( + types.InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="noop", + ) + ) + + if current_page < total_pages: + nav_row.append( + types.InlineKeyboardButton( + text=texts.t("PAGINATION_NEXT", "➡️"), + callback_data=f"menu_public_offer:{current_page + 1}", + ) + ) + + keyboard_rows.append(nav_row) + + keyboard_rows.append( + [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")] + ) + + await callback.message.edit_text( + message_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + async def show_language_menu( callback: types.CallbackQuery, db_user: User, @@ -511,6 +621,16 @@ def register_handlers(dp: Dispatcher): F.data.startswith("menu_privacy_policy:"), ) + dp.callback_query.register( + show_public_offer, + F.data == "menu_public_offer", + ) + + dp.callback_query.register( + show_public_offer, + F.data.startswith("menu_public_offer:"), + ) + dp.callback_query.register( show_language_menu, F.data == "menu_language" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 72a66fbd..2a59970b 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -184,6 +184,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM callback_data="admin_privacy_policy", ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"), + callback_data="admin_public_offer", + ) + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 5bfa995b..15624917 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -283,6 +283,7 @@ def get_main_menu_keyboard( def get_info_menu_keyboard( language: str = DEFAULT_LANGUAGE, show_privacy_policy: bool = False, + show_public_offer: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -296,6 +297,14 @@ def get_info_menu_keyboard( ) ]) + if show_public_offer: + buttons.append([ + InlineKeyboardButton( + text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"), + callback_data="menu_public_offer", + ) + ]) + buttons.append([ InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") ]) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 35328586..ffa15770 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -720,5 +720,42 @@ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.", "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.", "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below", - "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted" + "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted", + "MENU_PUBLIC_OFFER": "📄 Offer", + "PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.", + "PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.", + "PUBLIC_OFFER_HEADER": "📄 Public Offer", + "PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}", + "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer", + "ADMIN_PUBLIC_OFFER_HEADER": "📄 Public offer", + "ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.", + "ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: {lang}", + "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.", + "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}", + "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Text preview:", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.", + "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:", + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text", + "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text", + "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display", + "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display", + "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help", + "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):", + "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.", + "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.", + "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing", + "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel", + "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.", + "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}", + "ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.", + "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.", + "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Current offer text", + "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.", + "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled", + "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled", + "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing", + "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled." } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index f66baaec..06d7a051 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -235,10 +235,15 @@ "MENU_INFO_HEADER": "ℹ️ Инфо", "MENU_INFO_PROMPT": "Выберите раздел:", "MENU_PRIVACY_POLICY": "🛡️ Политика конф.", + "MENU_PUBLIC_OFFER": "📄 Оферта", "PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности", "PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.", "PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.", "PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}", + "PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.", + "PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.", + "PUBLIC_OFFER_HEADER": "📄 Публичная оферта", + "PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}", "MENU_LANGUAGE": "🌐 Язык", "MENU_PROMOCODE": "🎫 Промокод", "MENU_REFERRALS": "🤝 Партнерка", @@ -719,6 +724,38 @@ "ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена", "ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена", "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию", + "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта", + "ADMIN_PUBLIC_OFFER_HEADER": "📄 Публичная оферта", + "ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».", + "ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: {lang}", + "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.", + "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}", + "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Превью текста:", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.", + "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:", + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст", + "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст", + "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ", + "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ", + "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь", + "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):", + "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.", + "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.", + "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты", + "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена", + "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.", + "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}", + "ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.", + "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.", + "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Текущий текст оферты", + "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена", + "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена", + "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию", + "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.", "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n", "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:", "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены", diff --git a/app/services/public_offer_service.py b/app/services/public_offer_service.py new file mode 100644 index 00000000..053d48af --- /dev/null +++ b/app/services/public_offer_service.py @@ -0,0 +1,359 @@ +import logging +from html.parser import HTMLParser +from typing import List, Optional, Tuple + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.public_offer import ( + get_public_offer, + set_public_offer_enabled, + upsert_public_offer, +) +from app.database.models import PublicOffer + +logger = logging.getLogger(__name__) + + +class PublicOfferService: + """Helpers for managing the public offer text and visibility.""" + + MAX_PAGE_LENGTH = 3500 + + @staticmethod + def _normalize_language(language: str) -> str: + base_language = language or settings.DEFAULT_LANGUAGE or "ru" + return base_language.split("-")[0].lower() + + @staticmethod + def normalize_language(language: str) -> str: + return PublicOfferService._normalize_language(language) + + @classmethod + async def get_offer( + cls, + db: AsyncSession, + language: str, + *, + fallback: bool = False, + ) -> Optional[PublicOffer]: + lang = cls._normalize_language(language) + offer = await get_public_offer(db, lang) + + if offer or not fallback: + return offer + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang != default_lang: + return await get_public_offer(db, default_lang) + + return offer + + @classmethod + async def get_active_offer( + cls, + db: AsyncSession, + language: str, + ) -> Optional[PublicOffer]: + lang = cls._normalize_language(language) + offer = await get_public_offer(db, lang) + + if offer: + if offer.is_enabled and offer.content.strip(): + return offer + + if not offer.is_enabled: + return None + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang != default_lang: + fallback_offer = await get_public_offer(db, default_lang) + if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip(): + return fallback_offer + + return None + + @classmethod + async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool: + offer = await cls.get_active_offer(db, language) + return offer is not None + + @classmethod + async def save_offer( + cls, + db: AsyncSession, + language: str, + content: str, + ) -> PublicOffer: + lang = cls._normalize_language(language) + enable_if_new = True + offer = await upsert_public_offer( + db, + lang, + content, + enable_if_new=enable_if_new, + ) + logger.info("✅ Публичная оферта обновлена для языка %s", lang) + return offer + + @classmethod + async def set_enabled( + cls, + db: AsyncSession, + language: str, + enabled: bool, + ) -> PublicOffer: + lang = cls._normalize_language(language) + return await set_public_offer_enabled(db, lang, enabled) + + @classmethod + async def toggle_enabled( + cls, + db: AsyncSession, + language: str, + ) -> PublicOffer: + lang = cls._normalize_language(language) + offer = await get_public_offer(db, lang) + + if offer: + new_status = not offer.is_enabled + else: + new_status = True + + return await set_public_offer_enabled(db, lang, new_status) + + class _RichTextPaginator(HTMLParser): + """Split HTML-like text into Telegram-safe chunks.""" + + SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"} + + def __init__(self, max_len: int) -> None: + super().__init__(convert_charrefs=False) + self.max_len = max_len + self.pages: List[str] = [] + self.current_parts: List[str] = [] + self.current_length = 0 + self.open_stack: List[Tuple[str, str, int]] = [] + self.closing_length = 0 + self.needs_prefix = False + self.prefix_length = 0 + + def _closing_sequence(self) -> str: + return "".join(f"" for name, _, _ in reversed(self.open_stack)) + + def _ensure_prefix(self) -> None: + if not self.needs_prefix: + return + + prefix = "".join(token for _, token, _ in self.open_stack) + if prefix: + self.current_parts.append(prefix) + self.current_length += len(prefix) + self.needs_prefix = False + + def _flush(self) -> None: + if not self.current_parts and not self.closing_length: + return + + content = "".join(self.current_parts) + closing_tags = self._closing_sequence() + page = (content + closing_tags).strip() + if page: + self.pages.append(page) + + self.current_parts = [] + self.current_length = 0 + if self.prefix_length + self.closing_length > self.max_len: + self.open_stack = [] + self.closing_length = 0 + self.prefix_length = 0 + self.needs_prefix = False + else: + self.needs_prefix = bool(self.open_stack) + + @staticmethod + def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str: + parts = [] + for name, value in attrs: + if value is None: + parts.append(f" {name}") + else: + parts.append(f" {name}=\"{value}\"") + return "".join(parts) + + def _append_token(self, token: str) -> None: + while True: + self._ensure_prefix() + if self.current_length + len(token) + self.closing_length <= self.max_len: + self.current_parts.append(token) + self.current_length += len(token) + break + + self._flush() + + if len(token) > self.max_len and not self.current_parts: + self.current_parts.append(token) + self.current_length += len(token) + break + + def handle_data(self, data: str) -> None: + if not data: + return + + remaining = data + while remaining: + self._ensure_prefix() + available = self.max_len - (self.current_length + self.closing_length) + if available <= 0: + self._flush() + continue + + piece = remaining[:available] + self.current_parts.append(piece) + self.current_length += len(piece) + remaining = remaining[available:] + + if remaining: + self._flush() + + def handle_entityref(self, name: str) -> None: + self.handle_data(f"&{name};") + + def handle_charref(self, name: str) -> None: + self.handle_data(f"&#{name};") + + def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + token = f"<{tag}{self._format_attrs(attrs)}/>" + self._append_token(token) + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + if tag in self.SELF_CLOSING_TAGS: + self._handle_self_closing(tag, attrs) + return + + token = f"<{tag}{self._format_attrs(attrs)}>" + closing_token = f"" + closing_len = len(closing_token) + + while True: + self._ensure_prefix() + projected_length = self.current_length + len(token) + self.closing_length + closing_len + if projected_length <= self.max_len: + self.current_parts.append(token) + self.current_length += len(token) + self.open_stack.append((tag, token, closing_len)) + self.closing_length += closing_len + self.prefix_length += len(token) + break + + self._flush() + + if len(token) + closing_len > self.max_len and not self.current_parts: + self.current_parts.append(token) + self.current_length += len(token) + self.open_stack.append((tag, token, closing_len)) + self.closing_length += closing_len + self.prefix_length += len(token) + break + + def handle_endtag(self, tag: str) -> None: + token = f"" + closing_len_reduction = 0 + index_to_remove = None + for index in range(len(self.open_stack) - 1, -1, -1): + if self.open_stack[index][0] == tag: + closing_len_reduction = self.open_stack[index][2] + index_to_remove = index + break + + while True: + self._ensure_prefix() + projected_closing_length = self.closing_length - closing_len_reduction + if projected_closing_length < 0: + projected_closing_length = 0 + + projected_total = self.current_length + len(token) + projected_closing_length + if projected_total <= self.max_len or not self.current_parts: + self.current_parts.append(token) + self.current_length += len(token) + if index_to_remove is not None: + removed_tag = self.open_stack.pop(index_to_remove) + self.closing_length -= closing_len_reduction + self.prefix_length -= len(removed_tag[1]) + break + + self._flush() + + def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + self._handle_self_closing(tag, attrs) + + def finalize(self) -> List[str]: + self._flush() + return self.pages + + @classmethod + def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]: + if len(paragraph) <= max_len: + return [paragraph] + + paginator = cls._RichTextPaginator(max_len) + paginator.feed(paragraph) + paginator.close() + pages = paginator.finalize() + return pages or [paragraph] + + @classmethod + def split_content_into_pages( + cls, + content: str, + *, + max_length: int = None, + ) -> List[str]: + if not content: + return [] + + normalized = content.replace("\r\n", "\n").strip() + if not normalized: + return [] + + max_len = max_length or cls.MAX_PAGE_LENGTH + + if len(normalized) <= max_len: + return [normalized] + + paragraphs = [ + paragraph.strip() + for paragraph in normalized.split("\n\n") + if paragraph.strip() + ] + + pages: List[str] = [] + current = "" + + def flush_current() -> None: + nonlocal current + if current: + pages.append(current.strip()) + current = "" + + for paragraph in paragraphs: + segments = cls._split_rich_paragraph(paragraph, max_len) + for segment in segments: + segment = segment.strip() + if not segment: + continue + + candidate = f"{current}\n\n{segment}".strip() if current else segment + if len(candidate) <= max_len: + current = candidate + continue + + flush_current() + current = segment + + flush_current() + + if not pages: + return [normalized[:max_len]] + + return pages diff --git a/app/states.py b/app/states.py index 1e42df99..7527313b 100644 --- a/app/states.py +++ b/app/states.py @@ -88,6 +88,7 @@ class AdminStates(StatesGroup): editing_rules_page = State() editing_privacy_policy = State() + editing_public_offer = State() editing_notification_value = State() confirming_sync = State() diff --git a/locales/en.json b/locales/en.json index 54cf63dd..8869e22e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1052,5 +1052,42 @@ "ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.", "ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}", "ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}", - "ADMIN_PRICING_SETTING_WARNING": "Important" + "ADMIN_PRICING_SETTING_WARNING": "Important", + "MENU_PUBLIC_OFFER": "📄 Offer", + "PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.", + "PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.", + "PUBLIC_OFFER_HEADER": "📄 Public Offer", + "PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}", + "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer", + "ADMIN_PUBLIC_OFFER_HEADER": "📄 Public offer", + "ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.", + "ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: {lang}", + "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.", + "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}", + "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Text preview:", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.", + "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:", + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text", + "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text", + "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display", + "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display", + "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help", + "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):", + "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.", + "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.", + "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing", + "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel", + "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.", + "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}", + "ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.", + "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.", + "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Current offer text", + "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.", + "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled", + "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled", + "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing", + "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled." } diff --git a/locales/ru.json b/locales/ru.json index 32eba83d..868f9e4f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1016,5 +1016,42 @@ "ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.", "ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}", "ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}", - "ADMIN_PRICING_SETTING_WARNING": "Важно" + "ADMIN_PRICING_SETTING_WARNING": "Важно", + "MENU_PUBLIC_OFFER": "📄 Оферта", + "PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.", + "PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.", + "PUBLIC_OFFER_HEADER": "📄 Публичная оферта", + "PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}", + "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта", + "ADMIN_PUBLIC_OFFER_HEADER": "📄 Публичная оферта", + "ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».", + "ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: {lang}", + "ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.", + "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.", + "ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}", + "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "Превью текста:", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.", + "ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:", + "ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст", + "ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст", + "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ", + "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ", + "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь", + "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):", + "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.", + "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.", + "ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты", + "ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена", + "ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.", + "ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}", + "ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.", + "ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты", + "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.", + "ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 Текущий текст оферты", + "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + "ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена", + "ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена", + "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию", + "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено." }