feat: promocode and ref in web app

This commit is contained in:
3252a8
2026-04-24 13:52:53 +03:00
parent 9a84be85c6
commit bc29f5ebd6
7 changed files with 774 additions and 12 deletions
+277 -2
View File
@@ -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],
+105 -2
View File
@@ -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>
+331 -8
View File
@@ -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,
+2
View File
@@ -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)