feat: promocode and ref in web app
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot, Dispatcher
|
||||
@@ -20,6 +22,8 @@ from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from bot.services.platega_service import PlategaService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
@@ -65,6 +69,8 @@ def create_subscription_webapp_application(
|
||||
"cryptopay_service",
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
||||
@@ -90,6 +96,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/payments", create_payment_route)
|
||||
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
|
||||
|
||||
@@ -331,6 +338,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
payload = await _read_json(request)
|
||||
init_data = str(payload.get("init_data") or "")
|
||||
auth_data = payload.get("auth_data")
|
||||
referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
|
||||
telegram_user = None
|
||||
if init_data:
|
||||
telegram_user = validate_telegram_webapp_init_data(
|
||||
@@ -352,10 +360,28 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
authenticated_user_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
telegram_user,
|
||||
settings,
|
||||
referral_param=referral_param,
|
||||
)
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
referral_applied = await _apply_referral_to_existing_user(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
)
|
||||
if getattr(db_user, "_webapp_created", False) or referral_applied:
|
||||
await _apply_referral_welcome_bonus_if_needed(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
)
|
||||
authenticated_user_id = int(db_user.user_id)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
@@ -386,6 +412,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
payload = await _read_json(request)
|
||||
email = normalize_email(str(payload.get("email") or ""))
|
||||
code = str(payload.get("code") or "")
|
||||
referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
@@ -403,16 +430,38 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
created_user = False
|
||||
if not db_user:
|
||||
referred_by_id = await _resolve_referrer_id(
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
email=email,
|
||||
language_code=_normalize_language(settings.DEFAULT_LANGUAGE),
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
referred_by_id=referred_by_id,
|
||||
)
|
||||
created_user = True
|
||||
elif not db_user.email_verified_at:
|
||||
db_user.email_verified_at = datetime.now(timezone.utc)
|
||||
|
||||
referral_applied = await _apply_referral_to_existing_user(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param,
|
||||
)
|
||||
if created_user or referral_applied:
|
||||
await _apply_referral_welcome_bonus_if_needed(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
referral_param,
|
||||
)
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
@@ -569,6 +618,50 @@ async def me_route(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
|
||||
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
code = str(payload.get("code") or "").strip()
|
||||
if not code:
|
||||
return _json_error(400, "empty_code", "Promo code is empty")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
promo_code_service: PromoCodeService = request.app.get("promo_code_service")
|
||||
if not promo_code_service:
|
||||
return _json_error(503, "service_unavailable", "Promo service unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session,
|
||||
user_id,
|
||||
code,
|
||||
lang,
|
||||
)
|
||||
if not success:
|
||||
await session.rollback()
|
||||
return _json_error(400, "promo_apply_failed", str(result))
|
||||
await session.commit()
|
||||
end_date = result if isinstance(result, datetime) else None
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"end_date": end_date.isoformat() if end_date else None,
|
||||
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else None,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("WebApp promo apply failed: %s", exc, exc_info=True)
|
||||
return _json_error(500, "promo_apply_failed", "Promo apply failed")
|
||||
|
||||
|
||||
async def create_payment_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
@@ -832,10 +925,117 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
return None
|
||||
return value.upper()
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
if not ref_user:
|
||||
return None
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
return int(ref_user.user_id)
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
raw_referral_param: Optional[str],
|
||||
) -> bool:
|
||||
if not raw_referral_param or user.referred_by_id is not None:
|
||||
return False
|
||||
|
||||
referred_by_id = await _resolve_referrer_id(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
try:
|
||||
is_active_now = await subscription_service.has_active_subscription(
|
||||
session,
|
||||
int(user.user_id),
|
||||
)
|
||||
except Exception:
|
||||
is_active_now = False
|
||||
if is_active_now:
|
||||
return False
|
||||
|
||||
user.referred_by_id = referred_by_id
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def _apply_referral_welcome_bonus_if_needed(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
raw_referral_param: Optional[str],
|
||||
) -> Optional[datetime]:
|
||||
if not raw_referral_param or not user.referred_by_id:
|
||||
return None
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
referral_welcome_days = max(
|
||||
0,
|
||||
int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0),
|
||||
)
|
||||
if referral_welcome_days <= 0:
|
||||
return None
|
||||
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
try:
|
||||
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
int(user.user_id),
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_user_from_telegram(
|
||||
session: AsyncSession,
|
||||
telegram_user: Dict[str, Any],
|
||||
settings: Settings,
|
||||
*,
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
@@ -854,14 +1054,21 @@ async def _ensure_user_from_telegram(
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
db_user, _ = await user_dal.create_user(
|
||||
referred_by_id = await _resolve_referrer_id(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
**update_data,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
setattr(db_user, "_webapp_created", bool(created))
|
||||
return db_user
|
||||
|
||||
changed = {
|
||||
@@ -890,6 +1097,25 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
active = await subscription_service.get_active_subscription_details(
|
||||
session, user_id
|
||||
)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user)
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_link = None
|
||||
if referral_service and bot_username:
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session,
|
||||
bot_username,
|
||||
user_id,
|
||||
)
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
referral_stats = (
|
||||
await referral_service.get_referral_stats(session, user_id)
|
||||
if referral_service
|
||||
else {"invited_count": 0, "purchased_count": 0}
|
||||
)
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
user_id,
|
||||
@@ -913,6 +1139,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"language_code": lang,
|
||||
},
|
||||
"subscription": _serialize_subscription(active, local_sub, lang),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
"webapp_link": webapp_referral_link,
|
||||
"invited_count": referral_stats.get("invited_count", 0),
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(settings, lang),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"settings": {
|
||||
@@ -923,6 +1157,47 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str],
|
||||
referral_code: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
|
||||
@@ -184,13 +184,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
.account-panel,
|
||||
.promo-panel,
|
||||
.referral-panel {
|
||||
padding: 17px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -229,6 +238,93 @@
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.promo-status {
|
||||
min-height: 22px;
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.promo-status.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.promo-status.success {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 30;
|
||||
width: min(calc(100vw - 28px), 560px);
|
||||
max-height: min(86vh, 760px);
|
||||
overflow: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.tools-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.referral-link-list,
|
||||
.referral-bonuses,
|
||||
.bonus-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.referral-link-row,
|
||||
.bonus-row {
|
||||
min-height: 58px;
|
||||
padding: 11px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bonus-row {
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
}
|
||||
|
||||
.bonus-row span,
|
||||
.bonus-row strong,
|
||||
.referral-link-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bonus-row strong {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.referral-link-value {
|
||||
margin-top: 4px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.link-box,
|
||||
.field-stack {
|
||||
display: grid;
|
||||
@@ -1008,10 +1104,17 @@
|
||||
}
|
||||
|
||||
.account-row,
|
||||
.field-row {
|
||||
.field-row,
|
||||
.quick-actions,
|
||||
.referral-link-row,
|
||||
.bonus-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bonus-row strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,28 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quick-actions">
|
||||
<button class="btn full" type="button" onclick="openReferralModal()" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn full" type="button" onclick="openPromoModal()" data-i18n="promo_title">РџСЂРѕРјРѕРєРѕРґ</button>
|
||||
</section>
|
||||
<button id="tools-modal-backdrop" class="tools-modal-backdrop hidden" type="button" data-title-i18n="close" onclick="closeToolModals()"></button>
|
||||
|
||||
<section class="panel promo-panel hidden">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div class="section-title" data-i18n="promo_title">Промокод</div>
|
||||
<div class="flow-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input id="promo-code-input" class="input code-input" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn primary" type="button" onclick="applyPromoCode()" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel referral-panel hidden"></section>
|
||||
|
||||
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" class="legal-links hidden">
|
||||
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
|
||||
@@ -49,6 +49,17 @@ window.__WEBAPP_DEV_MOCK__ = {
|
||||
{id: 'platega', name: 'Platega'},
|
||||
{id: 'freekassa', name: 'FreeKassa / СБП'}
|
||||
],
|
||||
referral: {
|
||||
code: 'AB12CD34E',
|
||||
bot_link: 'https://t.me/preview_bot?start=ref_uAB12CD34E',
|
||||
webapp_link: 'https://app.example.com/?ref=uAB12CD34E',
|
||||
invited_count: 3,
|
||||
purchased_count: 1,
|
||||
bonus_details: [
|
||||
{months: 1, title: '1 месяц', inviter_days: 3, friend_days: 3},
|
||||
{months: 3, title: '3 месяца', inviter_days: 10, friend_days: 7}
|
||||
]
|
||||
},
|
||||
settings: {
|
||||
support_url: 'https://t.me/support',
|
||||
traffic_mode: false,
|
||||
@@ -72,8 +83,12 @@ const MOCK = (() => {
|
||||
data: null,
|
||||
selectedPlan: null,
|
||||
selectedMethod: null,
|
||||
referralParam: readReferralParam(),
|
||||
promoApplying: false,
|
||||
payment: null,
|
||||
paymentFlowOpen: false,
|
||||
promoModalOpen: false,
|
||||
referralModalOpen: false,
|
||||
paymentStep: 'plan',
|
||||
creatingPayment: false,
|
||||
authInProgress: false,
|
||||
@@ -85,6 +100,9 @@ const MOCK = (() => {
|
||||
emailLoginResending: false,
|
||||
emailLinkPending: false,
|
||||
emailLinkEmail: '',
|
||||
promoStatusTimer: null,
|
||||
promoStatusType: '',
|
||||
promoStatusText: '',
|
||||
telegramLinkRendered: false,
|
||||
telegramLinkInProgress: false,
|
||||
toastTimer: null
|
||||
@@ -176,7 +194,30 @@ const MOCK = (() => {
|
||||
no_link: 'Ссылка пока недоступна',
|
||||
payment_error: 'Ошибка оплаты',
|
||||
privacy_policy: 'Политика конфиденциальности',
|
||||
user_agreement: 'Пользовательское соглашение'
|
||||
user_agreement: 'Пользовательское соглашение',
|
||||
promo_title: 'Промокод',
|
||||
promo_caption: 'Введите код, чтобы начислить бонусные дни.',
|
||||
promo_placeholder: 'PROMO2026',
|
||||
apply_promo: 'Применить',
|
||||
promo_applied: 'Промокод применен',
|
||||
promo_applied_ok: 'Промокод применен, всё ок.',
|
||||
promo_empty: 'Введите промокод',
|
||||
promo_invalid: 'Промокод не найден или недействителен.',
|
||||
referral_title: 'Пригласить друга',
|
||||
referral_caption: 'Поделитесь кодом или ссылкой и получайте бонусы.',
|
||||
referral_code: 'Ваш код',
|
||||
invited_friends: 'Приглашено',
|
||||
purchased_friends: 'Оплатили',
|
||||
copy_code: 'Скопировать код',
|
||||
copy_webapp_link: 'Скопировать ссылку Web App',
|
||||
copy_bot_link: 'Скопировать ссылку бота',
|
||||
referral_copied: 'Ссылка скопирована',
|
||||
code_copied: 'Код скопирован',
|
||||
referral_site_label: 'Пригласить через сайт',
|
||||
referral_telegram_label: 'Пригласить через телеграм',
|
||||
referral_bonus_title: 'Бонусы',
|
||||
referral_bonus_pair: 'Вы: {inviter} дн. / друг: {friend} дн.',
|
||||
referral_no_bonuses: 'Бонусы пока не настроены.'
|
||||
},
|
||||
en: {
|
||||
page_title: 'My subscription',
|
||||
@@ -263,7 +304,30 @@ const MOCK = (() => {
|
||||
no_link: 'Link is not available yet',
|
||||
payment_error: 'Payment error',
|
||||
privacy_policy: 'Privacy policy',
|
||||
user_agreement: 'User agreement'
|
||||
user_agreement: 'User agreement',
|
||||
promo_title: 'Promo code',
|
||||
promo_caption: 'Enter a code to add bonus days.',
|
||||
promo_placeholder: 'PROMO2026',
|
||||
apply_promo: 'Apply',
|
||||
promo_applied: 'Promo code applied',
|
||||
promo_applied_ok: 'Promo code applied, all good.',
|
||||
promo_empty: 'Enter promo code',
|
||||
promo_invalid: 'Promo code was not found or is not valid.',
|
||||
referral_title: 'Invite friend',
|
||||
referral_caption: 'Share your code or link and receive bonuses.',
|
||||
referral_code: 'Your code',
|
||||
invited_friends: 'Invited',
|
||||
purchased_friends: 'Purchased',
|
||||
copy_code: 'Copy code',
|
||||
copy_webapp_link: 'Copy Web App link',
|
||||
copy_bot_link: 'Copy bot link',
|
||||
referral_copied: 'Link copied',
|
||||
code_copied: 'Code copied',
|
||||
referral_site_label: 'Invite via site',
|
||||
referral_telegram_label: 'Invite via Telegram',
|
||||
referral_bonus_title: 'Bonuses',
|
||||
referral_bonus_pair: 'You: {inviter} d. / friend: {friend} d.',
|
||||
referral_no_bonuses: 'Bonuses are not configured yet.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,11 +356,16 @@ const MOCK = (() => {
|
||||
|
||||
bindEmailLoginInput();
|
||||
bindEmailCodeInput();
|
||||
bindPromoInput();
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (state.emailLoginCodeModalOpen) {
|
||||
closeEmailLoginCodeModal();
|
||||
} else if (state.promoModalOpen) {
|
||||
closePromoModal();
|
||||
} else if (state.referralModalOpen) {
|
||||
closeReferralModal();
|
||||
} else if (state.paymentFlowOpen) {
|
||||
closePaymentFlow();
|
||||
}
|
||||
@@ -362,6 +431,20 @@ const MOCK = (() => {
|
||||
return authData;
|
||||
}
|
||||
|
||||
function readReferralParam() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const fromQuery = query.get('ref') || query.get('start') || query.get('start_param') || '';
|
||||
const fromTelegram = tg && tg.initDataUnsafe && tg.initDataUnsafe.start_param
|
||||
? tg.initDataUnsafe.start_param
|
||||
: '';
|
||||
const value = String(fromTelegram || fromQuery || '').trim();
|
||||
if (value) {
|
||||
localStorage.setItem('rw_webapp_referral', value);
|
||||
return value;
|
||||
}
|
||||
return localStorage.getItem('rw_webapp_referral') || '';
|
||||
}
|
||||
|
||||
function clearTelegramLoginWidgetQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
|
||||
@@ -379,6 +462,7 @@ const MOCK = (() => {
|
||||
const payload = source === 'init_data'
|
||||
? {init_data: authData}
|
||||
: {auth_data: authData};
|
||||
if (state.referralParam) payload.referral_code = state.referralParam;
|
||||
const response = await fetch(CFG.apiBase + '/auth/token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@@ -702,7 +786,11 @@ const MOCK = (() => {
|
||||
updateEmailLoginCodeSlots();
|
||||
setEmailCodeStatus(t('telegram_auth_verifying'));
|
||||
try {
|
||||
const data = await publicApi('/auth/email/verify', {email, code});
|
||||
const data = await publicApi('/auth/email/verify', {
|
||||
email,
|
||||
code,
|
||||
referral_code: state.referralParam || ''
|
||||
});
|
||||
if (!data.ok || !data.token) throw data;
|
||||
setToken(data.token);
|
||||
closeEmailLoginCodeModal();
|
||||
@@ -751,6 +839,7 @@ const MOCK = (() => {
|
||||
function render() {
|
||||
renderSubscription(state.data.subscription);
|
||||
renderAccount(state.data.user || {});
|
||||
renderReferral(state.data.referral || {});
|
||||
renderPaymentFlow();
|
||||
}
|
||||
|
||||
@@ -784,6 +873,229 @@ const MOCK = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function openPromoModal() {
|
||||
state.promoModalOpen = true;
|
||||
clearPromoStatus();
|
||||
renderPromoModal();
|
||||
window.setTimeout(() => {
|
||||
const input = document.getElementById('promo-code-input');
|
||||
if (input) input.focus();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function closePromoModal() {
|
||||
state.promoModalOpen = false;
|
||||
clearPromoStatus();
|
||||
renderPromoModal();
|
||||
}
|
||||
|
||||
function renderPromoModal() {
|
||||
const panel = document.querySelector('.promo-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hidden', !state.promoModalOpen);
|
||||
panel.classList.toggle('modal-panel', state.promoModalOpen);
|
||||
renderToolsBackdrop();
|
||||
syncModalLock();
|
||||
renderPromoStatus();
|
||||
if (state.promoModalOpen && !panel.querySelector('[data-modal-close="promo"]')) {
|
||||
const head = panel.querySelector('.panel-head');
|
||||
if (head) {
|
||||
head.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<button class="icon-btn" type="button" data-modal-close="promo" data-title-i18n="close" onclick="closePromoModal()">×</button>'
|
||||
);
|
||||
applyI18n(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPromoInput() {
|
||||
const input = document.getElementById('promo-code-input');
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (input.getAttribute('aria-invalid') === 'true') {
|
||||
input.removeAttribute('aria-invalid');
|
||||
}
|
||||
if (state.promoStatusText) {
|
||||
clearPromoStatus();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
applyPromoCode();
|
||||
});
|
||||
}
|
||||
|
||||
function setPromoStatus(message, type = 'error') {
|
||||
state.promoStatusType = type === 'success' ? 'success' : 'error';
|
||||
state.promoStatusText = normalizeStatusText(message || '');
|
||||
renderPromoStatus();
|
||||
}
|
||||
|
||||
function clearPromoStatus() {
|
||||
state.promoStatusType = '';
|
||||
state.promoStatusText = '';
|
||||
renderPromoStatus();
|
||||
}
|
||||
|
||||
function renderPromoStatus() {
|
||||
const status = document.getElementById('promo-status');
|
||||
if (!status) return;
|
||||
|
||||
if (!state.promoStatusText) {
|
||||
status.textContent = '';
|
||||
status.classList.add('hidden');
|
||||
status.classList.remove('error', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = state.promoStatusText;
|
||||
status.classList.remove('hidden');
|
||||
status.classList.toggle('error', state.promoStatusType === 'error');
|
||||
status.classList.toggle('success', state.promoStatusType === 'success');
|
||||
}
|
||||
|
||||
function normalizeStatusText(value) {
|
||||
const text = String(value || '');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = text;
|
||||
return (wrapper.textContent || wrapper.innerText || '').trim();
|
||||
}
|
||||
|
||||
function openReferralModal() {
|
||||
state.referralModalOpen = true;
|
||||
renderReferral(state.data && state.data.referral || {});
|
||||
}
|
||||
|
||||
function closeReferralModal() {
|
||||
state.referralModalOpen = false;
|
||||
renderReferral(state.data && state.data.referral || {});
|
||||
}
|
||||
|
||||
function closeToolModals() {
|
||||
state.promoModalOpen = false;
|
||||
state.referralModalOpen = false;
|
||||
clearPromoStatus();
|
||||
renderPromoModal();
|
||||
renderReferral(state.data && state.data.referral || {});
|
||||
}
|
||||
|
||||
function renderToolsBackdrop() {
|
||||
const backdrop = document.getElementById('tools-modal-backdrop');
|
||||
if (!backdrop) return;
|
||||
backdrop.classList.toggle('hidden', !(state.promoModalOpen || state.referralModalOpen));
|
||||
}
|
||||
|
||||
function renderReferral(referral) {
|
||||
const panel = document.querySelector('.referral-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hidden', !state.referralModalOpen);
|
||||
panel.classList.toggle('modal-panel', state.referralModalOpen);
|
||||
renderToolsBackdrop();
|
||||
syncModalLock();
|
||||
if (!state.referralModalOpen) return;
|
||||
|
||||
const bonusRows = (referral.bonus_details || []).map(item => `
|
||||
<div class="bonus-row">
|
||||
<span>${escapeHtml(item.title || '')}</span>
|
||||
<strong>${escapeHtml(t('referral_bonus_pair', {
|
||||
inviter: item.inviter_days || 0,
|
||||
friend: item.friend_days || 0
|
||||
}))}</strong>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div class="section-title">${escapeHtml(t('referral_title'))}</div>
|
||||
<div class="flow-caption">${escapeHtml(t('referral_caption'))}</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" onclick="closeReferralModal()">×</button>
|
||||
</div>
|
||||
<div class="referral-link-list">
|
||||
${renderReferralLinkRow('webapp', t('referral_site_label'), referral.webapp_link)}
|
||||
${renderReferralLinkRow('bot', t('referral_telegram_label'), referral.bot_link)}
|
||||
</div>
|
||||
<div class="referral-bonus-card">
|
||||
<div class="section-label">${escapeHtml(t('referral_bonus_title'))}</div>
|
||||
<div class="bonus-list">${bonusRows || '<div class="empty">' + escapeHtml(t('referral_no_bonuses')) + '</div>'}</div>
|
||||
</div>
|
||||
`;
|
||||
applyI18n(panel);
|
||||
}
|
||||
|
||||
function renderReferralLinkRow(kind, label, link) {
|
||||
return `
|
||||
<div class="referral-link-row">
|
||||
<div>
|
||||
<div class="metric-label">${escapeHtml(label)}</div>
|
||||
<div class="referral-link-value">${escapeHtml(link || t('not_available'))}</div>
|
||||
</div>
|
||||
<button class="btn icon-only copy-icon-btn" type="button" onclick="copyReferralLink('${escapeAttr(kind)}')" ${link ? '' : 'disabled'} data-title-i18n="copy_link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true"><path d="M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function applyPromoCode() {
|
||||
if (state.promoApplying) return;
|
||||
const input = document.getElementById('promo-code-input');
|
||||
const code = String(input && input.value || '').trim();
|
||||
if (!code) {
|
||||
setPromoStatus(t('promo_empty'), 'error');
|
||||
if (input) input.setAttribute('aria-invalid', 'true');
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
state.promoApplying = true;
|
||||
setButtonBusy('promo-apply-btn', true);
|
||||
clearPromoStatus();
|
||||
if (input) input.removeAttribute('aria-invalid');
|
||||
try {
|
||||
const data = await api('/promo/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({code})
|
||||
});
|
||||
if (!data.ok) throw data;
|
||||
if (input) input.value = '';
|
||||
setPromoStatus(t('promo_applied_ok'), 'success');
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
const errorCode = e && e.error;
|
||||
const serverMessage = e && (e.message || e.detail);
|
||||
if (input) input.setAttribute('aria-invalid', 'true');
|
||||
if (errorCode === 'empty_code') {
|
||||
setPromoStatus(t('promo_empty'), 'error');
|
||||
} else if (serverMessage) {
|
||||
setPromoStatus(serverMessage, 'error');
|
||||
} else if (errorCode === 'promo_apply_failed') {
|
||||
setPromoStatus(t('promo_invalid'), 'error');
|
||||
} else {
|
||||
setPromoStatus(t('payment_error'), 'error');
|
||||
}
|
||||
} finally {
|
||||
state.promoApplying = false;
|
||||
setButtonBusy('promo-apply-btn', false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReferralLink(kind) {
|
||||
const referral = state.data && state.data.referral;
|
||||
const link = referral && (kind === 'bot' ? referral.bot_link : referral.webapp_link);
|
||||
if (!link) {
|
||||
showToast(t('no_link'));
|
||||
return;
|
||||
}
|
||||
await copyText(link);
|
||||
showToast(t('referral_copied'));
|
||||
}
|
||||
|
||||
async function requestEmailLinkCode() {
|
||||
const input = document.getElementById('email-link-input');
|
||||
const email = normalizeEmail(input.value);
|
||||
@@ -970,7 +1282,12 @@ const MOCK = (() => {
|
||||
function syncModalLock() {
|
||||
document.body.classList.toggle(
|
||||
'modal-open',
|
||||
Boolean(state.paymentFlowOpen || state.emailLoginCodeModalOpen)
|
||||
Boolean(
|
||||
state.paymentFlowOpen
|
||||
|| state.emailLoginCodeModalOpen
|
||||
|| state.promoModalOpen
|
||||
|| state.referralModalOpen
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1183,17 +1500,20 @@ const MOCK = (() => {
|
||||
showToast(t('no_link'));
|
||||
return;
|
||||
}
|
||||
await copyText(link);
|
||||
showToast(t('link_copied'));
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
showToast(t('link_copied'));
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch (e) {
|
||||
const area = document.createElement('textarea');
|
||||
area.value = link;
|
||||
area.value = value;
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand('copy');
|
||||
area.remove();
|
||||
showToast(t('link_copied'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,6 +1569,9 @@ const MOCK = (() => {
|
||||
MOCK.data.user.telegram_id = 100200300;
|
||||
return {ok: true, token: 'local-preview'};
|
||||
}
|
||||
if (path === '/promo/apply') {
|
||||
return {ok: true, end_date_text: '20.05.2026 12:00'};
|
||||
}
|
||||
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -117,6 +117,8 @@ def validate_telegram_webapp_init_data(
|
||||
user_data = json.loads(user_json)
|
||||
if not user_data.get("id"):
|
||||
return None
|
||||
if parsed_data.get("start_param"):
|
||||
user_data["start_param"] = parsed_data.get("start_param")
|
||||
return user_data
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
|
||||
|
||||
@@ -2,9 +2,11 @@ import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from bot.services.referral_service import ReferralService
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
@@ -103,6 +105,16 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
bonus_details=bonus_details_str,
|
||||
invited_count=referral_stats["invited_count"],
|
||||
purchased_count=referral_stats["purchased_count"])
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
|
||||
webapp_referral_link = _build_webapp_referral_link(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
if webapp_referral_link:
|
||||
webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link"
|
||||
text += f"\n\n🔗 {webapp_label}:\n<code>{webapp_referral_link}</code>"
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
|
||||
@@ -167,3 +179,20 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
||||
await callback.answer("Произошла ошибка", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -49,6 +49,14 @@ def get_bot_interface_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_activate_trial_button"),
|
||||
callback_data="main_action:request_trial"))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_personal_account_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
|
||||
Reference in New Issue
Block a user