diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index ac08cd53..ab90a230 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -110,31 +110,6 @@ async def list_discount_offers( return result.scalars().all() -async def list_active_discount_offers_for_user( - db: AsyncSession, - user_id: int, -) -> List[DiscountOffer]: - """Return active (not yet claimed) offers for a user.""" - - now = datetime.utcnow() - stmt = ( - select(DiscountOffer) - .options( - selectinload(DiscountOffer.user), - selectinload(DiscountOffer.subscription), - ) - .where( - DiscountOffer.user_id == user_id, - DiscountOffer.is_active == True, # noqa: E712 - DiscountOffer.expires_at > now, - ) - .order_by(DiscountOffer.expires_at.asc()) - ) - - result = await db.execute(stmt) - return result.scalars().all() - - async def count_discount_offers( db: AsyncSession, *, diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index e1f05e51..09a130d1 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,8 +1,6 @@ from __future__ import annotations import logging -import re -from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException, status @@ -10,29 +8,15 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.discount_offer import ( - get_latest_claimed_offer_for_user, - get_offer_by_id, - list_active_discount_offers_for_user, - mark_offer_claimed, -) -from app.database.crud.promo_group import get_auto_assign_promo_groups -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, - PromoOfferTemplate, - Subscription, - Transaction, - User, -) +from app.database.models import PromoGroup, Subscription, Transaction, User from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) -from app.services.promo_offer_service import promo_offer_service from app.services.subscription_service import SubscriptionService from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( @@ -42,13 +26,10 @@ from app.utils.telegram_webapp import ( from ..dependencies import get_db_session from ..schemas.miniapp import ( - MiniAppAutoPromoGroupLevel, MiniAppConnectedServer, MiniAppDevice, + MiniAppAutoPromoGroupLevel, MiniAppPromoGroup, - MiniAppPromoOffer, - MiniAppPromoOfferClaimRequest, - MiniAppPromoOfferClaimResponse, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, MiniAppSubscriptionUser, @@ -85,353 +66,6 @@ def _format_limit_label(limit: Optional[int]) -> str: return f"{limit} GB" -_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") -_OFFER_TYPE_ICONS = { - "extend_discount": "πŸ’Ž", - "purchase_discount": "🎯", - "test_access": "πŸ§ͺ", -} -_EFFECT_TYPE_ICONS = { - "percent_discount": "🎁", - "test_access": "πŸ§ͺ", - "balance_bonus": "πŸ’°", -} -_DEFAULT_OFFER_ICON = "πŸŽ‰" - - -def _extract_template_id(notification_type: Optional[str]) -> Optional[int]: - if not notification_type: - return None - - match = _TEMPLATE_ID_PATTERN.match(notification_type) - if not match: - return None - - try: - return int(match.group("template_id")) - except (TypeError, ValueError): - return None - - -def _extract_offer_extra(offer: Any) -> Dict[str, Any]: - extra = getattr(offer, "extra_data", None) - return extra if isinstance(extra, dict) else {} - - -def _extract_offer_type(offer: Any, template: Optional[PromoOfferTemplate]) -> Optional[str]: - extra = _extract_offer_extra(offer) - offer_type = extra.get("offer_type") if isinstance(extra.get("offer_type"), str) else None - if offer_type: - return offer_type - template_type = getattr(template, "offer_type", None) - return template_type if isinstance(template_type, str) else None - - -def _normalize_effect_type(effect_type: Optional[str]) -> str: - normalized = (effect_type or "percent_discount").strip().lower() - if normalized == "balance_bonus": - return "percent_discount" - return normalized or "percent_discount" - - -def _determine_offer_icon(offer_type: Optional[str], effect_type: str) -> str: - if offer_type and offer_type in _OFFER_TYPE_ICONS: - return _OFFER_TYPE_ICONS[offer_type] - if effect_type in _EFFECT_TYPE_ICONS: - return _EFFECT_TYPE_ICONS[effect_type] - return _DEFAULT_OFFER_ICON - - -def _extract_offer_test_squad_uuids(offer: Any) -> List[str]: - extra = _extract_offer_extra(offer) - raw = extra.get("test_squad_uuids") or extra.get("squads") or [] - - if isinstance(raw, str): - raw = [raw] - - uuids: List[str] = [] - try: - for item in raw: - if not item: - continue - uuids.append(str(item)) - except TypeError: - return [] - - return uuids - - -def _format_offer_message( - template: Optional[PromoOfferTemplate], - offer: Any, - *, - server_name: Optional[str] = None, -) -> Optional[str]: - message_template: Optional[str] = None - - if template and isinstance(template.message_text, str): - message_template = template.message_text - else: - extra = _extract_offer_extra(offer) - raw_message = extra.get("message_text") or extra.get("text") - if isinstance(raw_message, str): - message_template = raw_message - - if not message_template: - return None - - extra = _extract_offer_extra(offer) - discount_percent = getattr(offer, "discount_percent", None) - try: - discount_percent = int(discount_percent) - except (TypeError, ValueError): - discount_percent = None - - replacements: Dict[str, Any] = {} - if discount_percent is not None: - replacements.setdefault("discount_percent", discount_percent) - - for key in ("valid_hours", "active_discount_hours", "test_duration_hours"): - value = extra.get(key) - if value is None and template is not None: - template_value = getattr(template, key, None) - else: - template_value = None - replacements.setdefault(key, value if value is not None else template_value) - - if replacements.get("active_discount_hours") is None and template: - replacements["active_discount_hours"] = getattr(template, "valid_hours", None) - - if replacements.get("test_duration_hours") is None and template: - replacements["test_duration_hours"] = getattr(template, "test_duration_hours", None) - - if server_name: - replacements.setdefault("server_name", server_name) - - for key, value in extra.items(): - if ( - isinstance(key, str) - and key not in replacements - and isinstance(value, (str, int, float)) - ): - replacements[key] = value - - try: - return message_template.format(**replacements) - except Exception: # pragma: no cover - fallback for malformed templates - return message_template - - -def _extract_offer_duration_hours( - offer: Any, - template: Optional[PromoOfferTemplate], - effect_type: str, -) -> Optional[int]: - extra = _extract_offer_extra(offer) - if effect_type == "test_access": - source = extra.get("test_duration_hours") - if source is None and template is not None: - source = getattr(template, "test_duration_hours", None) - else: - source = extra.get("active_discount_hours") - if source is None and template is not None: - source = getattr(template, "active_discount_hours", None) - - try: - if source is None: - return None - hours = int(float(source)) - return hours if hours > 0 else None - except (TypeError, ValueError): - return None - - -def _format_bonus_label(amount_kopeks: int) -> Optional[str]: - if amount_kopeks <= 0: - return None - try: - return settings.format_price(amount_kopeks) - except Exception: # pragma: no cover - defensive - return f"{amount_kopeks / 100:.2f}" - - -async def _build_promo_offer_models( - db: AsyncSession, - available_offers: List[Any], - active_offer: Optional[Any], - *, - user: User, - active_discount_percent: int, - active_discount_expires_at: Optional[datetime], -) -> List[MiniAppPromoOffer]: - promo_offers: List[MiniAppPromoOffer] = [] - template_cache: Dict[int, Optional[PromoOfferTemplate]] = {} - - candidates: List[Any] = [offer for offer in available_offers if offer] - if active_offer: - candidates.append(active_offer) - - squad_map: Dict[str, MiniAppConnectedServer] = {} - if candidates: - all_uuids: List[str] = [] - for offer in candidates: - all_uuids.extend(_extract_offer_test_squad_uuids(offer)) - if all_uuids: - unique = list(dict.fromkeys(all_uuids)) - resolved = await _resolve_connected_servers(db, unique) - squad_map = {server.uuid: server for server in resolved} - - async def get_template(template_id: Optional[int]) -> Optional[PromoOfferTemplate]: - if not template_id: - return None - if template_id not in template_cache: - template_cache[template_id] = await get_promo_offer_template_by_id(db, template_id) - return template_cache[template_id] - - def build_test_squads(offer: Any) -> List[MiniAppConnectedServer]: - test_squads: List[MiniAppConnectedServer] = [] - for uuid in _extract_offer_test_squad_uuids(offer): - resolved = squad_map.get(uuid) - if resolved: - test_squads.append( - MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name) - ) - else: - test_squads.append(MiniAppConnectedServer(uuid=uuid, name=uuid)) - return test_squads - - def resolve_title( - offer: Any, - template: Optional[PromoOfferTemplate], - offer_type: Optional[str], - ) -> Optional[str]: - extra = _extract_offer_extra(offer) - if isinstance(extra.get("title"), str) and extra["title"].strip(): - return extra["title"].strip() - if template and template.name: - return template.name - if offer_type: - return offer_type.replace("_", " ").title() - return None - - for offer in available_offers: - template_id = _extract_template_id(getattr(offer, "notification_type", None)) - template = await get_template(template_id) - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - offer_type = _extract_offer_type(offer, template) - test_squads = build_test_squads(offer) - server_name = test_squads[0].name if test_squads else None - message_text = _format_offer_message(template, offer, server_name=server_name) - bonus_label = _format_bonus_label(int(getattr(offer, "bonus_amount_kopeks", 0) or 0)) - discount_percent = getattr(offer, "discount_percent", 0) - try: - discount_percent = int(discount_percent) - except (TypeError, ValueError): - discount_percent = 0 - - extra = _extract_offer_extra(offer) - button_text = None - if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): - button_text = extra["button_text"].strip() - elif template and isinstance(template.button_text, str): - button_text = template.button_text - - promo_offers.append( - MiniAppPromoOffer( - id=int(getattr(offer, "id", 0) or 0), - status="pending", - notification_type=getattr(offer, "notification_type", None), - offer_type=offer_type, - effect_type=effect_type, - discount_percent=max(0, discount_percent), - bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), - bonus_amount_label=bonus_label, - expires_at=getattr(offer, "expires_at", None), - claimed_at=getattr(offer, "claimed_at", None), - is_active=bool(getattr(offer, "is_active", False)), - template_id=template_id, - template_name=getattr(template, "name", None), - button_text=button_text, - title=resolve_title(offer, template, offer_type), - message_text=message_text, - icon=_determine_offer_icon(offer_type, effect_type), - test_squads=test_squads, - ) - ) - - active_offer_record = active_offer - if active_offer_record: - template_id = _extract_template_id(getattr(active_offer_record, "notification_type", None)) - template = await get_template(template_id) - effect_type = _normalize_effect_type(getattr(active_offer_record, "effect_type", None)) - offer_type = _extract_offer_type(active_offer_record, template) - show_active = False - discount_value = active_discount_percent - if discount_value > 0: - show_active = True - elif effect_type == "test_access": - show_active = True - if show_active: - test_squads = build_test_squads(active_offer_record) - server_name = test_squads[0].name if test_squads else None - message_text = _format_offer_message(template, active_offer_record, server_name=server_name) - bonus_label = _format_bonus_label(int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0)) - - started_at = getattr(active_offer_record, "claimed_at", None) - expires_at = active_discount_expires_at - duration_seconds: Optional[int] = None - duration_hours = _extract_offer_duration_hours(active_offer_record, template, effect_type) - if expires_at is None and duration_hours and started_at: - expires_at = started_at + timedelta(hours=duration_hours) - if expires_at and started_at: - try: - duration_seconds = int((expires_at - started_at).total_seconds()) - except Exception: # pragma: no cover - defensive - duration_seconds = None - - if discount_value <= 0: - try: - discount_value = int(getattr(active_offer_record, "discount_percent", 0) or 0) - except (TypeError, ValueError): - discount_value = 0 - - extra = _extract_offer_extra(active_offer_record) - button_text = None - if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): - button_text = extra["button_text"].strip() - elif template and isinstance(template.button_text, str): - button_text = template.button_text - - promo_offers.insert( - 0, - MiniAppPromoOffer( - id=int(getattr(active_offer_record, "id", 0) or 0), - status="active", - notification_type=getattr(active_offer_record, "notification_type", None), - offer_type=offer_type, - effect_type=effect_type, - discount_percent=max(0, discount_value), - bonus_amount_kopeks=int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0), - bonus_amount_label=bonus_label, - expires_at=getattr(active_offer_record, "expires_at", None), - claimed_at=started_at, - is_active=False, - template_id=template_id, - template_name=getattr(template, "name", None), - button_text=button_text, - title=resolve_title(active_offer_record, template, offer_type), - message_text=message_text, - icon=_determine_offer_icon(offer_type, effect_type), - test_squads=test_squads, - active_discount_expires_at=expires_at, - active_discount_started_at=started_at, - active_discount_duration_seconds=duration_seconds, - ), - ) - - return promo_offers - - def _bytes_to_gb(bytes_value: Optional[int]) -> float: if not bytes_value: return 0.0 @@ -727,38 +361,6 @@ async def get_subscription_details( ) ) - active_discount_percent = 0 - try: - active_discount_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) - except (TypeError, ValueError): - active_discount_percent = 0 - - active_discount_expires_at = getattr(user, "promo_offer_discount_expires_at", None) - now = datetime.utcnow() - if active_discount_expires_at and active_discount_expires_at <= now: - active_discount_expires_at = None - active_discount_percent = 0 - - available_promo_offers = await list_active_discount_offers_for_user(db, user.id) - - promo_offer_source = getattr(user, "promo_offer_discount_source", None) - active_claimed_offer = None - if promo_offer_source or active_discount_percent > 0: - active_claimed_offer = await get_latest_claimed_offer_for_user( - db, - user.id, - promo_offer_source, - ) - - promo_offers = await _build_promo_offer_models( - db, - available_promo_offers, - active_claimed_offer, - user=user, - active_discount_percent=active_discount_percent, - active_discount_expires_at=active_discount_expires_at, - ) - response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -785,9 +387,6 @@ 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=active_discount_percent, - promo_offer_discount_expires_at=active_discount_expires_at, - promo_offer_discount_source=promo_offer_source, ) return MiniAppSubscriptionResponse( @@ -811,7 +410,6 @@ async def get_subscription_details( balance_rubles=round(user.balance_rubles, 2), balance_currency=balance_currency, transactions=[_serialize_transaction(tx) for tx in transactions], - promo_offers=promo_offers, promo_group=( MiniAppPromoGroup( id=promo_group.id, @@ -830,151 +428,6 @@ 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: - try: - webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": 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.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "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, - detail={"code": "offer_not_found", "message": "Offer not found"}, - ) - - now = datetime.utcnow() - if offer.claimed_at is not None: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail={"code": "already_claimed", "message": "Offer already claimed"}, - ) - - if not offer.is_active or offer.expires_at <= now: - offer.is_active = False - await db.commit() - raise HTTPException( - status.HTTP_410_GONE, - detail={"code": "offer_expired", "message": "Offer expired"}, - ) - - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - - if effect_type == "test_access": - success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( - db, - user, - offer, - ) - - if not success: - code = error_code or "claim_failed" - message_map = { - "subscription_missing": "Active subscription required", - "squads_missing": "No squads configured for test access", - "already_connected": "Servers already connected", - "remnawave_sync_failed": "Failed to apply servers", - } - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": code, "message": message_map.get(code, "Unable to activate offer")}, - ) - - 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, - }, - ) - - return MiniAppPromoOfferClaimResponse(success=True, code="test_access_claimed") - - discount_percent = int(getattr(offer, "discount_percent", 0) or 0) - if discount_percent <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_discount", "message": "Offer does not contain discount"}, - ) - - user.promo_offer_discount_percent = discount_percent - user.promo_offer_discount_source = offer.notification_type - user.updated_at = now - - extra_data = _extract_offer_extra(offer) - 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 - else: - template = None - - 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 - - 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 MiniAppPromoOfferClaimResponse(success=True, code="discount_claimed") 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 e2c2a348..360e6fa5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -34,9 +34,6 @@ 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_expires_at: Optional[datetime] = None - promo_offer_discount_source: Optional[str] = None class MiniAppPromoGroup(BaseModel): @@ -90,39 +87,6 @@ class MiniAppTransaction(BaseModel): completed_at: Optional[datetime] = None -class MiniAppPromoOffer(BaseModel): - id: int - status: str - notification_type: Optional[str] = None - offer_type: Optional[str] = None - effect_type: Optional[str] = None - discount_percent: int = 0 - bonus_amount_kopeks: int = 0 - bonus_amount_label: Optional[str] = None - expires_at: Optional[datetime] = None - claimed_at: Optional[datetime] = None - is_active: bool = False - template_id: Optional[int] = None - template_name: Optional[str] = None - button_text: Optional[str] = None - title: Optional[str] = None - message_text: Optional[str] = None - icon: Optional[str] = None - test_squads: List[MiniAppConnectedServer] = Field(default_factory=list) - active_discount_expires_at: Optional[datetime] = None - active_discount_started_at: Optional[datetime] = None - active_discount_duration_seconds: Optional[int] = None - - -class MiniAppPromoOfferClaimRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppPromoOfferClaimResponse(BaseModel): - success: bool = True - code: Optional[str] = None - - class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: int @@ -145,7 +109,6 @@ class MiniAppSubscriptionResponse(BaseModel): balance_rubles: float = 0.0 balance_currency: Optional[str] = None transactions: List[MiniAppTransaction] = Field(default_factory=list) - promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list) promo_group: Optional[MiniAppPromoGroup] = None auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list) total_spent_kopeks: int = 0 diff --git a/miniapp/index.html b/miniapp/index.html index 244cc0f5..7161ee85 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -397,209 +397,6 @@ padding: 0 16px 16px; } - .promo-offers { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 16px; - } - - .promo-offer-card { - position: relative; - padding: 18px; - border-radius: var(--radius-lg); - border: 1px solid rgba(var(--primary-rgb), 0.12); - background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.04)); - color: var(--text-primary); - box-shadow: var(--shadow-md); - overflow: hidden; - } - - .promo-offer-card.active { - background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(var(--primary-rgb), 0.08)); - border-color: rgba(var(--primary-rgb), 0.2); - } - - .promo-offer-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; - } - - .promo-offer-icon { - width: 44px; - height: 44px; - border-radius: 14px; - background: rgba(var(--primary-rgb), 0.12); - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; - } - - .promo-offer-card.active .promo-offer-icon { - background: rgba(255, 255, 255, 0.18); - color: #fff; - } - - .promo-offer-heading { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; - } - - .promo-offer-title { - font-size: 18px; - font-weight: 700; - } - - .promo-offer-subtitle { - font-size: 13px; - color: var(--text-secondary); - } - - .promo-offer-card.active .promo-offer-subtitle { - color: rgba(255, 255, 255, 0.85); - } - - .promo-offer-badge { - font-weight: 700; - font-size: 16px; - padding: 6px 12px; - border-radius: 999px; - background: rgba(var(--primary-rgb), 0.16); - color: var(--text-primary); - } - - .promo-offer-card.active .promo-offer-badge { - background: rgba(255, 255, 255, 0.2); - color: #fff; - } - - .promo-offer-message { - font-size: 14px; - line-height: 1.55; - color: inherit; - } - - .promo-offer-details { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; - } - - .promo-offer-chip { - padding: 6px 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 600; - background: rgba(var(--primary-rgb), 0.15); - color: var(--text-primary); - } - - .promo-offer-footer { - display: flex; - align-items: center; - gap: 12px; - margin-top: 16px; - flex-wrap: wrap; - } - - .promo-offer-timer { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - } - - .promo-offer-card.active .promo-offer-timer { - color: rgba(255, 255, 255, 0.9); - } - - .promo-offer-action { - margin-left: auto; - display: flex; - align-items: center; - gap: 8px; - } - - .promo-offer-btn { - padding: 10px 20px; - border-radius: var(--radius-lg); - border: none; - font-weight: 700; - font-size: 14px; - cursor: pointer; - background: var(--primary); - color: var(--tg-theme-button-text-color); - transition: all 0.2s ease; - box-shadow: var(--shadow-sm); - } - - .promo-offer-btn:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - } - - .promo-offer-btn:disabled { - opacity: 0.6; - cursor: default; - box-shadow: none; - } - - .promo-offer-progress { - position: relative; - height: 6px; - border-radius: 999px; - overflow: hidden; - background: rgba(var(--primary-rgb), 0.18); - margin-top: 12px; - } - - .promo-offer-progress-bar { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.55)); - transform-origin: left; - transition: width 0.4s ease; - } - - .promo-offer-card:not(.active) .promo-offer-progress { - background: rgba(var(--primary-rgb), 0.12); - } - - .promo-offer-card:not(.active) .promo-offer-progress-bar { - background: linear-gradient(90deg, rgba(var(--primary-rgb), 0.6), rgba(var(--primary-rgb), 0.3)); - } - - :root[data-theme="dark"] .promo-offer-card { - background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.24), rgba(255, 255, 255, 0.04)); - border-color: rgba(255, 255, 255, 0.08); - } - - :root[data-theme="dark"] .promo-offer-card.active { - background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.4), rgba(var(--primary-rgb), 0.2)); - border-color: rgba(255, 255, 255, 0.18); - } - - :root[data-theme="dark"] .promo-offer-icon { - background: rgba(255, 255, 255, 0.12); - } - - :root[data-theme="dark"] .promo-offer-card.active .promo-offer-icon { - background: rgba(255, 255, 255, 0.2); - } - - :root[data-theme="dark"] .promo-offer-chip { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.9); - } - /* User Card Specific */ .user-card { background: linear-gradient(135deg, var(--bg-secondary), rgba(var(--primary-rgb), 0.05)); @@ -1610,19 +1407,6 @@ padding: 12px; } - .promo-offer-card { - padding: 16px; - } - - .promo-offer-title { - font-size: 16px; - } - - .promo-offer-badge { - font-size: 14px; - padding: 5px 10px; - } - .stats-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; @@ -1844,9 +1628,6 @@