diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 9454217b..933d58f1 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -9,11 +9,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
-from app.database.crud.discount_offer import (
- get_offer_by_id,
- mark_offer_claimed,
-)
-from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.discount_offer import get_offer_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
@@ -5242,35 +5238,13 @@ async def claim_discount_offer(
)
return
- now = datetime.utcnow()
- if offer.claimed_at is not None:
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
- show_alert=True,
- )
- return
+ result = await promo_offer_service.claim_offer(db, db_user, offer)
- if not offer.is_active or offer.expires_at <= now:
- offer.is_active = False
- await db.commit()
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
- show_alert=True,
- )
- return
+ if not result.success:
+ error_code = (result.error_code or "unknown").lower()
+ effect_type = result.effect_type or "percent_discount"
- effect_type = (offer.effect_type or "percent_discount").lower()
- if effect_type == "balance_bonus":
- effect_type = "percent_discount"
-
- if effect_type == "test_access":
- success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
- db,
- db_user,
- offer,
- )
-
- if not success:
+ if effect_type == "test_access":
if error_code == "subscription_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SUBSCRIPTION",
@@ -5296,19 +5270,35 @@ async def claim_discount_offer(
"TEST_ACCESS_UNKNOWN_ERROR",
"❌ Не удалось активировать предложение. Попробуйте позже.",
)
- await callback.answer(error_message, show_alert=True)
- return
+ else:
+ if error_code == "already_claimed":
+ error_message = texts.get(
+ "DISCOUNT_CLAIM_ALREADY",
+ "ℹ️ Скидка уже была активирована",
+ )
+ elif error_code in {"expired", "inactive"}:
+ error_message = texts.get(
+ "DISCOUNT_CLAIM_EXPIRED",
+ "⚠️ Время действия предложения истекло",
+ )
+ elif error_code == "no_discount":
+ error_message = texts.get(
+ "DISCOUNT_CLAIM_ERROR",
+ "❌ Не удалось активировать скидку. Попробуйте позже.",
+ )
+ else:
+ error_message = texts.get(
+ "DISCOUNT_CLAIM_ERROR",
+ "❌ Не удалось активировать скидку. Попробуйте позже.",
+ )
- await mark_offer_claimed(
- db,
- offer,
- details={
- "context": "test_access_claim",
- "new_squads": newly_added,
- "expires_at": expires_at.isoformat() if expires_at else None,
- },
- )
+ await callback.answer(error_message, show_alert=True)
+ return
+ effect_type = (result.effect_type or "percent_discount").lower()
+
+ if effect_type == "test_access":
+ expires_at = result.test_access_expires_at
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
success_message = texts.get(
"TEST_ACCESS_ACTIVATED_MESSAGE",
@@ -5330,52 +5320,11 @@ async def claim_discount_offer(
await callback.message.answer(success_message, reply_markup=back_keyboard)
return
- discount_percent = int(offer.discount_percent or 0)
- if discount_percent <= 0:
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
- show_alert=True,
- )
- return
-
- db_user.promo_offer_discount_percent = discount_percent
- db_user.promo_offer_discount_source = offer.notification_type
- db_user.updated_at = now
-
+ discount_percent = result.discount_percent
+ discount_expires_at = result.discount_expires_at
+ duration_hours = result.discount_duration_hours
extra_data = offer.extra_data or {}
- raw_duration = extra_data.get("active_discount_hours")
- template_id = extra_data.get("template_id")
-
- if raw_duration in (None, "") and template_id:
- try:
- template = await get_promo_offer_template_by_id(db, int(template_id))
- except (ValueError, TypeError):
- template = None
- if template and template.active_discount_hours:
- raw_duration = template.active_discount_hours
-
- try:
- duration_hours = int(raw_duration) if raw_duration is not None else None
- except (TypeError, ValueError):
- duration_hours = None
-
- if duration_hours and duration_hours > 0:
- discount_expires_at = now + timedelta(hours=duration_hours)
- else:
- discount_expires_at = None
-
- db_user.promo_offer_discount_expires_at = discount_expires_at
-
- await mark_offer_claimed(
- db,
- offer,
- details={
- "context": "discount_claim",
- "discount_percent": discount_percent,
- "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
- },
- )
- await db.refresh(db_user)
+ now = datetime.utcnow()
success_template = texts.get(
"DISCOUNT_CLAIM_SUCCESS",
@@ -5388,6 +5337,15 @@ async def claim_discount_offer(
format_values: Dict[str, Any] = {"percent": discount_percent}
+ if duration_hours is None and isinstance(extra_data, dict):
+ raw_duration = extra_data.get("active_discount_hours") or extra_data.get("duration_hours")
+ try:
+ parsed_duration = int(raw_duration) if raw_duration is not None else None
+ except (TypeError, ValueError):
+ parsed_duration = None
+ if parsed_duration and parsed_duration > 0:
+ duration_hours = parsed_duration
+
if duration_hours and duration_hours > 0:
format_values.setdefault("hours", duration_hours)
format_values.setdefault("duration_hours", duration_hours)
diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py
index 11de8c5e..c23fcf2f 100644
--- a/app/services/promo_offer_service.py
+++ b/app/services/promo_offer_service.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Sequence, Tuple
@@ -8,6 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
+from app.database.crud.discount_offer import mark_offer_claimed
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.models import (
DiscountOffer,
Subscription,
@@ -20,6 +23,18 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
logger = logging.getLogger(__name__)
+@dataclass
+class PromoOfferClaimOutcome:
+ success: bool
+ effect_type: str
+ discount_percent: int = 0
+ discount_expires_at: Optional[datetime] = None
+ discount_duration_hours: Optional[int] = None
+ test_access_expires_at: Optional[datetime] = None
+ newly_added_squads: List[str] = field(default_factory=list)
+ error_code: Optional[str] = None
+
+
class PromoOfferService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
@@ -131,6 +146,118 @@ class PromoOfferService:
return True, newly_added, expires_at, "ok"
+ async def claim_offer(
+ self,
+ db: AsyncSession,
+ user: User,
+ offer: DiscountOffer,
+ ) -> PromoOfferClaimOutcome:
+ effect_type_raw = (offer.effect_type or "percent_discount").lower()
+ effect_type = "percent_discount" if effect_type_raw == "balance_bonus" else effect_type_raw
+
+ now = datetime.utcnow()
+
+ if offer.claimed_at is not None:
+ return PromoOfferClaimOutcome(False, effect_type, error_code="already_claimed")
+
+ if not offer.is_active:
+ return PromoOfferClaimOutcome(False, effect_type, error_code="inactive")
+
+ if offer.expires_at <= now:
+ offer.is_active = False
+ await db.commit()
+ return PromoOfferClaimOutcome(False, effect_type, error_code="expired")
+
+ if effect_type == "test_access":
+ success, newly_added, expires_at, error_code = await self.grant_test_access(
+ db,
+ user,
+ offer,
+ )
+
+ if not success:
+ return PromoOfferClaimOutcome(
+ False,
+ effect_type,
+ error_code=error_code or "unknown",
+ )
+
+ await mark_offer_claimed(
+ db,
+ offer,
+ details={
+ "context": "test_access_claim",
+ "new_squads": newly_added,
+ "expires_at": expires_at.isoformat() if expires_at else None,
+ },
+ )
+
+ await db.refresh(user)
+
+ return PromoOfferClaimOutcome(
+ True,
+ effect_type,
+ newly_added_squads=newly_added or [],
+ test_access_expires_at=expires_at,
+ )
+
+ try:
+ discount_percent = int(offer.discount_percent or 0)
+ except (TypeError, ValueError):
+ discount_percent = 0
+
+ if discount_percent <= 0:
+ return PromoOfferClaimOutcome(False, effect_type, error_code="no_discount")
+
+ user.promo_offer_discount_percent = discount_percent
+ user.promo_offer_discount_source = offer.notification_type
+ user.updated_at = now
+
+ extra_data = offer.extra_data or {}
+ raw_duration = extra_data.get("active_discount_hours")
+ template_id = extra_data.get("template_id")
+
+ if (raw_duration in (None, "")) and template_id:
+ try:
+ template = await get_promo_offer_template_by_id(db, int(template_id))
+ except (TypeError, ValueError):
+ template = None
+ if template and template.active_discount_hours:
+ raw_duration = template.active_discount_hours
+
+ try:
+ duration_hours = int(raw_duration) if raw_duration is not None else None
+ except (TypeError, ValueError):
+ duration_hours = None
+
+ discount_expires_at: Optional[datetime] = None
+ if duration_hours and duration_hours > 0:
+ discount_expires_at = now + timedelta(hours=duration_hours)
+
+ user.promo_offer_discount_expires_at = discount_expires_at
+
+ await mark_offer_claimed(
+ db,
+ offer,
+ details={
+ "context": "discount_claim",
+ "discount_percent": discount_percent,
+ "discount_expires_at": (
+ discount_expires_at.isoformat() if discount_expires_at else None
+ ),
+ },
+ )
+
+ await db.refresh(user)
+
+ return PromoOfferClaimOutcome(
+ True,
+ effect_type,
+ discount_percent=discount_percent,
+ discount_expires_at=discount_expires_at,
+ discount_duration_hours=duration_hours if duration_hours and duration_hours > 0 else None,
+ )
+
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index 09a130d1..0ac3e8b2 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -1,6 +1,9 @@
from __future__ import annotations
+import html
import logging
+import re
+from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import APIRouter, Depends, HTTPException, status
@@ -8,11 +11,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
+from app.database.crud.discount_offer import get_offer_by_id, list_discount_offers
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import PromoGroup, Subscription, Transaction, User
+from app.services.promo_offer_service import promo_offer_service
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
@@ -29,7 +35,10 @@ from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
MiniAppAutoPromoGroupLevel,
+ MiniAppPromoOffer,
MiniAppPromoGroup,
+ MiniAppPromoOfferClaimRequest,
+ MiniAppPromoOfferClaimResponse,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
MiniAppSubscriptionUser,
@@ -218,6 +227,129 @@ def _is_remnawave_configured() -> bool:
return bool(params.get("base_url") and params.get("api_key"))
+def _sanitize_offer_message(text: Optional[str]) -> Optional[str]:
+ if not text:
+ return None
+
+ normalized = text.replace("\r\n", "\n")
+ normalized = re.sub(r"
", "\n", normalized, flags=re.IGNORECASE)
+ stripped = re.sub(r"<[^>]+>", "", normalized)
+ unescaped = html.unescape(stripped)
+ cleaned = unescaped.strip()
+ return cleaned or None
+
+
+def _parse_init_payload(init_data: str) -> int:
+ try:
+ webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN)
+ except TelegramWebAppAuthError as error:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=str(error),
+ ) from error
+
+ telegram_user = webapp_data.get("user")
+ if not isinstance(telegram_user, dict) or "id" not in telegram_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid Telegram user payload",
+ )
+
+ try:
+ return int(telegram_user["id"])
+ except (TypeError, ValueError) as error:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid Telegram user identifier",
+ ) from error
+
+
+async def _load_available_promo_offers(
+ db: AsyncSession,
+ user: User,
+) -> List[MiniAppPromoOffer]:
+ offers = await list_discount_offers(
+ db,
+ user_id=user.id,
+ is_active=True,
+ limit=20,
+ )
+
+ now = datetime.utcnow()
+ templates_cache: Dict[int, Optional[Any]] = {}
+ result: List[MiniAppPromoOffer] = []
+
+ for offer in offers:
+ if offer.claimed_at is not None:
+ continue
+ if offer.expires_at <= now:
+ continue
+
+ extra_data = offer.extra_data if isinstance(offer.extra_data, dict) else {}
+
+ template_id: Optional[int] = None
+ raw_template_id = extra_data.get("template_id")
+ if raw_template_id is not None:
+ try:
+ template_id = int(raw_template_id)
+ except (TypeError, ValueError):
+ template_id = None
+
+ template = None
+ if template_id is not None:
+ if template_id in templates_cache:
+ template = templates_cache[template_id]
+ else:
+ template = await get_promo_offer_template_by_id(db, template_id)
+ templates_cache[template_id] = template
+
+ name = None
+ button_text = None
+ message_text = None
+
+ if template is not None:
+ name = template.name
+ button_text = template.button_text
+ message_text = template.message_text
+ else:
+ name = extra_data.get("title") or extra_data.get("name")
+ button_text = extra_data.get("button_text") or extra_data.get("buttonText")
+ message_text = (
+ extra_data.get("message_text")
+ or extra_data.get("messageText")
+ or extra_data.get("text")
+ )
+
+ sanitized_message = _sanitize_offer_message(message_text)
+
+ try:
+ discount_percent = int(offer.discount_percent or 0)
+ except (TypeError, ValueError):
+ discount_percent = 0
+
+ try:
+ bonus_amount = int(offer.bonus_amount_kopeks or 0)
+ except (TypeError, ValueError):
+ bonus_amount = 0
+
+ result.append(
+ MiniAppPromoOffer(
+ id=offer.id,
+ template_id=template_id,
+ name=name,
+ message=sanitized_message,
+ button_text=button_text,
+ discount_percent=discount_percent,
+ bonus_amount_kopeks=bonus_amount,
+ expires_at=offer.expires_at,
+ effect_type=offer.effect_type or "percent_discount",
+ extra_data=extra_data,
+ )
+ )
+
+ return result
+
+
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
return MiniAppTransaction(
id=transaction.id,
@@ -266,28 +398,7 @@ async def get_subscription_details(
payload: MiniAppSubscriptionRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionResponse:
- try:
- webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
- except TelegramWebAppAuthError as error:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=str(error),
- ) from error
-
- telegram_user = webapp_data.get("user")
- if not isinstance(telegram_user, dict) or "id" not in telegram_user:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Invalid Telegram user payload",
- )
-
- try:
- telegram_id = int(telegram_user["id"])
- except (TypeError, ValueError):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Invalid Telegram user identifier",
- ) from None
+ telegram_id = _parse_init_payload(payload.init_data)
user = await get_user_by_telegram_id(db, telegram_id)
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
@@ -361,6 +472,8 @@ async def get_subscription_details(
)
)
+ available_promo_offers = await _load_available_promo_offers(db, user)
+
response_user = MiniAppSubscriptionUser(
telegram_id=user.telegram_id,
username=user.username,
@@ -387,6 +500,14 @@ async def get_subscription_details(
traffic_limit_label=_format_limit_label(traffic_limit),
lifetime_used_traffic_gb=lifetime_used,
has_active_subscription=status_actual in {"active", "trial"},
+ promo_offer_discount_percent=
+ int(getattr(user, "promo_offer_discount_percent", 0) or 0),
+ promo_offer_discount_source=getattr(user, "promo_offer_discount_source", None),
+ promo_offer_discount_expires_at=getattr(
+ user,
+ "promo_offer_discount_expires_at",
+ None,
+ ),
)
return MiniAppSubscriptionResponse(
@@ -420,6 +541,7 @@ async def get_subscription_details(
else None
),
auto_assign_promo_groups=auto_promo_levels,
+ promo_offers=available_promo_offers,
total_spent_kopeks=total_spent_kopeks,
total_spent_rubles=round(total_spent_kopeks / 100, 2),
total_spent_label=settings.format_price(total_spent_kopeks),
@@ -428,6 +550,45 @@ async def get_subscription_details(
branding=settings.get_miniapp_branding(),
)
+
+@router.post(
+ "/promo-offers/{offer_id}/claim",
+ response_model=MiniAppPromoOfferClaimResponse,
+)
+async def claim_promo_offer(
+ offer_id: int,
+ payload: MiniAppPromoOfferClaimRequest,
+ db: AsyncSession = Depends(get_db_session),
+) -> MiniAppPromoOfferClaimResponse:
+ telegram_id = _parse_init_payload(payload.init_data)
+
+ user = await get_user_by_telegram_id(db, telegram_id)
+ if not user:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
+
+ offer = await get_offer_by_id(db, offer_id)
+ if not offer or offer.user_id != user.id:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found")
+
+ result = await promo_offer_service.claim_offer(db, user, offer)
+
+ if not result.success:
+ return MiniAppPromoOfferClaimResponse(
+ success=False,
+ effect_type=result.effect_type,
+ error_code=result.error_code or "unknown",
+ )
+
+ return MiniAppPromoOfferClaimResponse(
+ success=True,
+ effect_type=result.effect_type,
+ discount_percent=result.discount_percent or None,
+ discount_expires_at=result.discount_expires_at,
+ test_access_expires_at=result.test_access_expires_at,
+ newly_added_squads=result.newly_added_squads,
+ )
+
+
def _safe_int(value: Any) -> int:
try:
return int(value)
diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py
index 360e6fa5..a0450992 100644
--- a/app/webapi/schemas/miniapp.py
+++ b/app/webapi/schemas/miniapp.py
@@ -34,6 +34,9 @@ class MiniAppSubscriptionUser(BaseModel):
traffic_limit_label: str
lifetime_used_traffic_gb: float = 0.0
has_active_subscription: bool = False
+ promo_offer_discount_percent: int = 0
+ promo_offer_discount_source: Optional[str] = None
+ promo_offer_discount_expires_at: Optional[datetime] = None
class MiniAppPromoGroup(BaseModel):
@@ -61,6 +64,19 @@ class MiniAppAutoPromoGroupLevel(BaseModel):
apply_discounts_to_addons: bool = True
+class MiniAppPromoOffer(BaseModel):
+ id: int
+ template_id: Optional[int] = None
+ name: Optional[str] = None
+ message: Optional[str] = None
+ button_text: Optional[str] = None
+ discount_percent: int = 0
+ bonus_amount_kopeks: int = 0
+ expires_at: datetime
+ effect_type: str = "percent_discount"
+ extra_data: Dict[str, Any] = Field(default_factory=dict)
+
+
class MiniAppConnectedServer(BaseModel):
uuid: str
name: str
@@ -111,6 +127,7 @@ class MiniAppSubscriptionResponse(BaseModel):
transactions: List[MiniAppTransaction] = Field(default_factory=list)
promo_group: Optional[MiniAppPromoGroup] = None
auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
+ promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
total_spent_kopeks: int = 0
total_spent_rubles: float = 0.0
total_spent_label: Optional[str] = None
@@ -118,3 +135,17 @@ class MiniAppSubscriptionResponse(BaseModel):
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None
+
+class MiniAppPromoOfferClaimRequest(BaseModel):
+ init_data: str = Field(..., alias="initData")
+
+
+class MiniAppPromoOfferClaimResponse(BaseModel):
+ success: bool = True
+ effect_type: Optional[str] = None
+ discount_percent: Optional[int] = None
+ discount_expires_at: Optional[datetime] = None
+ test_access_expires_at: Optional[datetime] = None
+ newly_added_squads: List[str] = Field(default_factory=list)
+ error_code: Optional[str] = None
+
diff --git a/miniapp/index.html b/miniapp/index.html
index 7161ee85..da1f2429 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -55,6 +55,25 @@
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
}
+ :root[data-theme="dark"] .promo-offer-active {
+ background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.32), rgba(var(--primary-rgb), 0.62));
+ color: #f8fafc;
+ }
+
+ :root[data-theme="dark"] .promo-offer-active-timer-value {
+ background: rgba(15, 23, 42, 0.45);
+ }
+
+ :root[data-theme="dark"] .promo-offer-card {
+ background: rgba(30, 41, 59, 0.92);
+ border-color: rgba(148, 163, 184, 0.3);
+ }
+
+ :root[data-theme="dark"] .promo-offer-tag.neutral {
+ background: rgba(148, 163, 184, 0.2);
+ color: var(--text-primary);
+ }
+
:root[data-theme="light"] {
color-scheme: light;
}
@@ -84,6 +103,212 @@
padding-bottom: 32px;
}
+ .promo-offer-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 24px;
+ }
+
+ .promo-offer-active {
+ position: relative;
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(var(--primary-rgb), 0.42));
+ color: #ffffff;
+ overflow: hidden;
+ box-shadow: var(--shadow-md);
+ }
+
+ .promo-offer-active::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.35), transparent 55%);
+ pointer-events: none;
+ }
+
+ .promo-offer-active-header {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ }
+
+ .promo-offer-active-title {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ }
+
+ .promo-offer-active-subtitle {
+ font-size: 13px;
+ opacity: 0.85;
+ margin-top: 6px;
+ }
+
+ .promo-offer-active-percent {
+ font-size: 34px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ text-align: right;
+ }
+
+ .promo-offer-active-timer {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 16px;
+ position: relative;
+ z-index: 1;
+ }
+
+ .promo-offer-active-timer-label {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ opacity: 0.75;
+ }
+
+ .promo-offer-active-timer-value {
+ font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+ font-size: 18px;
+ font-weight: 700;
+ padding: 6px 12px;
+ border-radius: var(--radius);
+ background: rgba(255, 255, 255, 0.22);
+ letter-spacing: 0.08em;
+ }
+
+ .promo-offer-active-meta {
+ margin-top: 14px;
+ font-size: 13px;
+ opacity: 0.9;
+ position: relative;
+ z-index: 1;
+ }
+
+ .promo-offer-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ .promo-offer-list.hidden {
+ display: none;
+ }
+
+ .promo-offer-list-header {
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: 0.02em;
+ color: var(--text-primary);
+ }
+
+ .promo-offer-card {
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ padding: 18px;
+ box-shadow: var(--shadow-sm);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .promo-offer-card-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ }
+
+ .promo-offer-card-message {
+ font-size: 14px;
+ color: var(--text-secondary);
+ line-height: 1.55;
+ word-break: break-word;
+ }
+
+ .promo-offer-card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .promo-offer-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ background: rgba(var(--primary-rgb), 0.12);
+ color: var(--primary);
+ }
+
+ .promo-offer-tag.muted {
+ background: rgba(148, 163, 184, 0.16);
+ color: var(--text-secondary);
+ }
+
+ .promo-offer-tag.neutral {
+ background: rgba(255, 255, 255, 0.12);
+ color: var(--text-primary);
+ }
+
+ .promo-offer-card-footer {
+ margin-top: 4px;
+ display: flex;
+ justify-content: flex-start;
+ }
+
+ .promo-offer-button {
+ position: relative;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--primary);
+ color: var(--tg-theme-button-text-color);
+ font-weight: 700;
+ font-size: 15px;
+ padding: 10px 18px;
+ cursor: pointer;
+ box-shadow: var(--shadow-sm);
+ transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
+ }
+
+ .promo-offer-button:hover:not(.loading) {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+ }
+
+ .promo-offer-button:active:not(.loading) {
+ transform: translateY(0);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .promo-offer-button.loading {
+ opacity: 0.75;
+ pointer-events: none;
+ }
+
+ .promo-offer-button.loading::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 18px;
+ height: 18px;
+ margin: -9px 0 0 -9px;
+ border-radius: 50%;
+ border: 2px solid rgba(255, 255, 255, 0.6);
+ border-top-color: transparent;
+ animation: spin 0.8s linear infinite;
+ }
+
/* Animations */
@keyframes fadeIn {
from {
@@ -1596,6 +1821,24 @@
+