/* WEBAPP_DEV_MOCK_START */
window.__WEBAPP_DEV_MOCK__ = {
config: {
title: 'Моя подписка',
primaryColor: '#00fe7a',
logoUrl: '',
apiBase: '/api',
supportUrl: 'https://t.me/support',
privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB',
language: 'ru',
emailAuthEnabled: true,
telegramLoginBotUsername: 'preview_bot'
},
data: {
ok: true,
user: {
id: 100200300,
username: 'preview',
email: '',
email_verified: false,
telegram_id: 100200300,
telegram_linked: true,
first_name: 'Preview',
language_code: 'ru'
},
subscription: {
active: true,
status: 'ACTIVE',
remaining_text: '27 д. 5 ч.',
end_date_text: '19.05.2026 22:40',
days_left: 27,
config_link: 'https://sub.example.com/sub/preview-token',
connect_url: 'https://sub.example.com/connect/preview-token',
traffic_used: '18.42 GB',
traffic_limit: '500.00 GB',
traffic_used_bytes: 19778468741,
traffic_limit_bytes: 536870912000,
auto_renew_enabled: false,
provider: null
},
plans: [
{months: 1, price: 190, currency: 'RUB', title: '1 месяц'},
{months: 3, price: 550, currency: 'RUB', title: '3 месяца'},
{months: 6, price: 1100, currency: 'RUB', title: '6 месяцев'},
{months: 12, price: 2000, currency: 'RUB', title: '12 месяцев'}
],
payment_methods: [
{id: 'cryptopay', name: 'CryptoPay'},
{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,
email_auth_enabled: true
}
}
};
/* WEBAPP_DEV_MOCK_END */
const MOCK = (() => {
const mock = window.__WEBAPP_DEV_MOCK__;
const host = window.location.hostname;
const isLocal = window.location.protocol === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === '';
return mock && isLocal ? mock : null;
})();
function readJsonScript(id) {
const node = document.getElementById(id);
if (!node || !node.textContent) return null;
try {
return JSON.parse(node.textContent);
} catch (error) {
console.warn('Failed to parse JSON config from #' + id, error);
return null;
}
}
const CFG = readJsonScript('webapp-config') || (MOCK && MOCK.config) || {};
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
const state = {
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
data: null,
selectedPlan: null,
referralParam: readReferralParam(),
promoApplying: false,
payment: null,
paymentFlowOpen: false,
promoModalOpen: false,
referralModalOpen: false,
accountMergeModalOpen: false,
accountMergeNotice: null,
creatingPayment: false,
authInProgress: false,
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
emailLoginPending: false,
emailLoginEmail: '',
emailLoginCodeModalOpen: false,
emailLoginVerifying: false,
emailLoginResending: false,
emailLinkPending: false,
emailLinkEmail: '',
promoStatusTimer: null,
promoStatusType: '',
promoStatusText: '',
telegramLinkRendered: false,
telegramLinkInProgress: false,
toastTimer: null,
userMenuOpen: false,
userMenuCloseTimer: null,
langOverride: (function() {
try { return localStorage.getItem('rw_webapp_lang') || ''; } catch (e) { return ''; }
})(),
langMenuOpen: false,
langMenuCloseTimer: null
};
const LANG_OPTIONS = [
{ code: 'ru', label: 'Русский', short: 'RU', flag: '🇷🇺' },
{ code: 'en', label: 'English', short: 'EN', flag: '🇬🇧' }
];
const TW = {
iconBtn: 'icon-btn',
btnBase: 'btn',
btnIconOnly: 'w-12 min-w-12 p-0',
panelHead: 'panel-head',
flowCaption: 'section-caption',
sectionTitle: 'section-title',
metricLabel: 'metric-label',
metricValue: 'metric-value',
referralLinkRow: 'referral-link-row',
referralLinkValue: 'referral-link-value',
bonusRow: 'bonus-row',
empty: 'empty-state',
planCard: 'plan-card',
planCardActive: 'plan-card-active',
planName: 'plan-name',
planMeta: 'plan-meta',
planPrice: 'plan-price',
paymentMethodCard: 'payment-method-card',
paymentMethodCardPlatega: 'payment-method-card--platega',
paymentMethodCardCryptopay: 'payment-method-card--cryptopay',
notice: 'notice',
stepNum: 'step-num',
stepName: 'step-name'
};
const FALLBACK_I18N = {
ru: {
page_title: 'Моя подписка',
loading: 'Загрузка...',
refresh: 'Обновить',
logout: 'Выйти',
subscription: 'Подписка',
subscription_subtitle: 'Ваша ссылка подключения и срок доступа',
ends_at: 'Окончание',
traffic: 'Трафик',
connect: 'Подключиться',
copy_link: 'Скопировать ссылку',
extend_subscription: 'Купить подписку / Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.',
close: 'Закрыть',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
step_method: 'Метод',
step_result: 'Платеж',
select_period: 'Выберите период',
select_method: 'Выберите способ оплаты',
payment_status: 'Статус платежа',
payment_amount_label: 'Стоимость выбранного периода',
choose_payment_method: 'Способ оплаты',
back: 'Назад',
create_payment: 'Создать платеж',
open_payment: 'Открыть оплату',
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
pay_with_platega_button: 'Оплатить картой (СБП)',
pay_with_cryptopay_button: 'Оплатить криптой',
support: 'Поддержка',
account_title: 'Аккаунт',
account_caption: 'Способы входа',
email_label: 'Email',
telegram_label: 'Telegram',
linked: 'Привязан',
not_linked: 'Не привязан',
email_login_tab: 'Email',
telegram_login_tab: 'Telegram',
email_placeholder: 'mail@example.com',
code_placeholder: '000000',
send_code: 'Отправить код',
resend_code: 'Отправить еще раз',
confirm: 'Подтвердить',
login: 'Войти',
login_title: 'Войдите или зарегистрируйтесь',
login_continue: 'Продолжить',
email_code_title: 'Подтвердите вход',
email_code_caption: 'Введите 6-значный код из письма.',
email_code_aria: 'Код подтверждения',
email_auth_disabled: 'Вход по email пока не настроен.',
email_required: 'Введите email',
email_invalid: 'Введите корректный email',
email_code_sending: 'Отправляю код...',
email_code_sent: 'Код отправлен на почту',
email_code_send_failed: 'Не удалось отправить код',
email_code_invalid: 'Неверный код',
email_code_expired: 'Код устарел',
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
email_linked: 'Email привязан',
telegram_linked: 'Telegram привязан',
account_merge_toast: 'Аккаунты объединены',
account_merge_title: 'Аккаунты объединены',
account_merge_caption: 'Оплаченные периоды сложились, и теперь у вас один аккаунт.',
account_merge_body: 'Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.',
account_merge_primary_account_label: 'Оставлен аккаунт',
account_merge_removed_account_label: 'Удалён аккаунт',
account_merge_end_date_label: 'Новая дата окончания',
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
telegram_auth_unavailable: 'Telegram Login Widget недоступен. Проверьте username бота и доступ к telegram.org.',
telegram_auth_access_denied: 'Доступ запрещен.',
active: 'Активна',
inactive: 'Не активна',
subscription_active: 'Подписка активна',
subscription_inactive: 'Подписка не активна',
sub_remaining_template: 'Действует еще {value}',
sub_ends_template: 'Закончится {date}',
sub_ended_template: 'Закончилась {date}',
sub_no_expiry: 'Бессрочно',
traffic_unlimited: '∞ бесконечный трафик',
link_panel_title: 'Привяжите способ входа',
link_panel_caption: 'Чтобы не потерять доступ к кабинету',
link_email_title: 'Привяжите email',
link_email_caption: 'Получайте код для входа на почту',
link_telegram_title: 'Привяжите Telegram',
link_telegram_caption: 'Входите одним нажатием через Telegram',
guest_user: 'Гость',
no_active_subscription: 'Нет активной подписки',
not_available: 'N/A',
no_plans: 'Тарифы не настроены.',
access_period: 'Срок доступа',
choose_plan_first: 'Сначала выберите срок подписки.',
no_methods: 'Нет доступных способов оплаты.',
payment_not_created: 'Платеж еще не создан.',
invoice_sent: 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.',
payment_created: 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.',
updating: 'Обновляю данные',
choose_plan_toast: 'Выберите срок подписки',
choose_method_toast: 'Выберите способ оплаты',
create_payment_toast: 'Создайте платеж',
payment_confirmed: 'Оплата подтверждена',
payment_pending: 'Платеж пока не подтвержден',
link_copied: 'Ссылка скопирована',
no_link: 'Ссылка пока недоступна',
payment_error: 'Ошибка оплаты',
privacy_policy: 'Политика конфиденциальности',
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: 'Бонусы пока не настроены.',
referral_bonus_explanation: 'Бонусы начисляются один раз за каждого приглашенного пользователя, когда приглашенный пользователь покупает подписку.'
},
en: {
page_title: 'My subscription',
loading: 'Loading...',
refresh: 'Refresh',
logout: 'Log out',
subscription: 'Subscription',
subscription_subtitle: 'Your connection link and access time',
ends_at: 'Ends at',
traffic: 'Traffic',
connect: 'Connect',
copy_link: 'Copy link',
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period and tap a payment method. The payment service will open right away.',
close: 'Close',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
step_plan: 'Period',
step_method: 'Method',
step_result: 'Payment',
select_period: 'Select period',
select_method: 'Select payment method',
payment_status: 'Payment status',
payment_amount_label: 'Selected period cost',
choose_payment_method: 'Payment method',
back: 'Back',
create_payment: 'Create payment',
open_payment: 'Open payment',
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
pay_with_platega_button: 'Pay by card (SBP)',
pay_with_cryptopay_button: 'Pay with crypto',
support: 'Support',
account_title: 'Account',
account_caption: 'Sign-in methods',
email_label: 'Email',
telegram_label: 'Telegram',
linked: 'Linked',
not_linked: 'Not linked',
email_login_tab: 'Email',
telegram_login_tab: 'Telegram',
email_placeholder: 'mail@example.com',
code_placeholder: '000000',
send_code: 'Send code',
resend_code: 'Send again',
confirm: 'Confirm',
login: 'Log in',
login_title: 'Log in or sign up',
login_continue: 'Continue',
email_code_title: 'Confirm login',
email_code_caption: 'Enter the 6-digit code from the email.',
email_code_aria: 'Verification code',
email_auth_disabled: 'Email sign-in is not configured yet.',
email_required: 'Enter your email',
email_invalid: 'Enter a valid email address',
email_code_sending: 'Sending code...',
email_code_sent: 'Code sent to email',
email_code_send_failed: 'Could not send code',
email_code_invalid: 'Invalid code',
email_code_expired: 'Code expired',
email_rate_limited: 'Try again in {seconds} sec.',
email_linked: 'Email linked',
telegram_linked: 'Telegram linked',
account_merge_toast: 'Accounts merged',
account_merge_title: 'Accounts merged',
account_merge_caption: 'Your paid periods were combined and you now have one account.',
account_merge_body: 'We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.',
account_merge_primary_account_label: 'Kept account',
account_merge_removed_account_label: 'Removed account',
account_merge_end_date_label: 'New end date',
account_merge_conflict: 'This account is already linked to different data.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
telegram_auth_unavailable: 'Telegram Login Widget is unavailable. Check the bot username and access to telegram.org.',
telegram_auth_access_denied: 'Access denied.',
active: 'Active',
inactive: 'Inactive',
subscription_active: 'Subscription is active',
subscription_inactive: 'Subscription is inactive',
sub_remaining_template: 'Valid for {value} more',
sub_ends_template: 'Expires {date}',
sub_ended_template: 'Expired {date}',
sub_no_expiry: 'No expiration',
traffic_unlimited: '∞ unlimited traffic',
link_panel_title: 'Link a sign-in method',
link_panel_caption: 'So you never lose access',
link_email_title: 'Link email',
link_email_caption: 'Receive sign-in codes by email',
link_telegram_title: 'Link Telegram',
link_telegram_caption: 'Sign in with one tap via Telegram',
guest_user: 'Guest',
no_active_subscription: 'No active subscription',
not_available: 'N/A',
no_plans: 'Plans are not configured.',
access_period: 'Access period',
choose_plan_first: 'Choose a subscription period first.',
no_methods: 'No payment methods available.',
payment_not_created: 'Payment has not been created yet.',
invoice_sent: 'The invoice was sent to the bot chat. Pay it, then return here and check the status.',
payment_created: 'Payment was created. Open it, complete payment with the provider, then check the status.',
updating: 'Refreshing data',
choose_plan_toast: 'Choose a subscription period',
choose_method_toast: 'Choose a payment method',
create_payment_toast: 'Create a payment',
payment_confirmed: 'Payment confirmed',
payment_pending: 'Payment is not confirmed yet',
link_copied: 'Link copied',
no_link: 'Link is not available yet',
payment_error: 'Payment error',
privacy_policy: 'Privacy policy',
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 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.',
referral_bonus_explanation: 'Bonuses are awarded once for each invited user when they purchase a subscription.'
}
};
const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || {};
const accent = CFG.primaryColor || '#00fe7a';
document.documentElement.style.setProperty('--accent', accent);
setBrand();
applyI18n();
applyLegalLinks();
if (CFG.supportUrl) {
const support = document.getElementById('support-link');
support.href = CFG.supportUrl;
support.classList.remove('hidden');
}
if (tg) {
try {
tg.ready();
tg.expand();
const canSetColors = typeof tg.isVersionAtLeast === 'function' && tg.isVersionAtLeast('6.1');
if (canSetColors) {
tg.setHeaderColor('#05070a');
tg.setBackgroundColor('#05070a');
}
} catch (e) { }
}
bindEmailLoginInput();
bindEmailCodeInput();
bindPromoInput();
document.addEventListener('keydown', event => {
handleDocumentKeyForUserMenu(event);
if (event.key !== 'Escape') return;
if (state.emailLoginCodeModalOpen) {
closeEmailLoginCodeModal();
} else if (state.accountMergeModalOpen) {
closeAccountMergeModal();
} else if (state.promoModalOpen) {
closePromoModal();
} else if (state.referralModalOpen) {
closeReferralModal();
} else if (state.paymentFlowOpen) {
closePaymentFlow();
}
});
document.addEventListener('click', handleDocumentActionClick);
document.addEventListener('click', handleDocumentClickForUserMenu);
document.addEventListener('click', handleDocumentClickForLangMenu);
renderLangMenu();
boot();
async function boot() {
showLoader();
if (MOCK) {
await loadData();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData) {
const authenticated = await finalizeTelegramAuth(widgetAuthData);
if (authenticated) {
return;
}
clearToken();
startExternalAuth({resetStatus: false});
return;
}
if (tg && tg.initData) {
try {
const authenticated = await finalizeTelegramAuth(tg.initData, 'init_data');
if (authenticated) {
return;
}
} catch (e) { }
}
if (state.token) {
try {
await loadData();
return;
} catch (e) {
clearToken();
}
}
await startExternalAuth();
}
function readTelegramLoginWidgetAuthData() {
const query = new URLSearchParams(window.location.search);
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
const authData = {};
let hasAuthValue = false;
keys.forEach(key => {
if (!query.has(key)) return;
authData[key] = query.get(key) || '';
hasAuthValue = true;
});
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) {
return null;
}
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'];
keys.forEach(key => url.searchParams.delete(key));
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
}
async function finalizeTelegramAuth(authData, source = 'auth_data') {
if (state.authInProgress) return false;
state.authInProgress = true;
setAuthStatus(t('telegram_auth_verifying'));
try {
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'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.ok && data.token) {
setToken(data.token);
clearTelegramLoginWidgetQuery();
try {
setAuthStatus('');
await loadData();
return true;
} catch (e) {
clearToken();
setAuthStatus(t('telegram_auth_failed'), true);
return false;
}
}
const errorKey = data.error === 'banned' || data.error === 'access_denied'
? 'telegram_auth_access_denied'
: 'telegram_auth_failed';
setAuthStatus(t(errorKey), true);
} catch (e) {
setAuthStatus(t('telegram_auth_failed'), true);
} finally {
state.authInProgress = false;
}
clearTelegramLoginWidgetQuery();
return false;
}
async function handleTelegramLoginWidgetAuth(user) {
if (!user) {
setAuthStatus(t('telegram_auth_failed'), true);
return;
}
await finalizeTelegramAuth(user, 'auth_data');
}
function setAuthMode(mode) {
state.authMode = mode === 'telegram' ? 'telegram' : 'email';
renderAuthMode();
}
function renderAuthMode() {
const emailTab = document.getElementById('email-auth-tab');
const telegramTab = document.getElementById('telegram-auth-tab');
const emailPane = document.getElementById('email-login-pane');
const telegramPane = document.getElementById('telegram-login-pane');
const authBody = document.querySelector('.login-auth-body');
const emailEnabled = CFG.emailAuthEnabled !== false;
if (!emailEnabled && state.authMode === 'email') {
state.authMode = 'telegram';
}
emailTab.classList.toggle('active', state.authMode === 'email');
telegramTab.classList.toggle('active', state.authMode === 'telegram');
emailPane.classList.toggle('hidden', state.authMode !== 'email');
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
if (authBody) {
authBody.classList.toggle('login-auth-body--telegram', state.authMode === 'telegram');
}
emailTab.disabled = !emailEnabled;
if (state.authMode === 'telegram') {
renderTelegramLoginWidget();
} else if (!emailEnabled) {
setAuthStatus(t('email_auth_disabled'), true);
}
}
function renderTelegramLoginWidget() {
const container = document.getElementById('telegram-login-widget');
if (!container) return;
container.innerHTML = '';
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
if (!botUsername) {
setAuthStatus(t('telegram_auth_unavailable'), true);
return;
}
if (typeof window.onTelegramAuth !== 'function') {
window.onTelegramAuth = async function(user) {
await handleTelegramLoginWidgetAuth(user);
};
}
const script = document.createElement('script');
script.async = true;
script.src = TELEGRAM_LOGIN_WIDGET_URL;
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-userpic', 'true');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramAuth(user)');
script.onerror = () => setAuthStatus(t('telegram_auth_unavailable'), true);
container.appendChild(script);
}
function bindEmailLoginInput() {
const input = document.getElementById('email-login-input');
if (!input) return;
input.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault();
requestEmailLoginCode();
});
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') !== 'true') return;
input.removeAttribute('aria-invalid');
setAuthStatus('');
});
}
function bindEmailCodeInput() {
const input = document.getElementById('email-login-code-input');
if (!input) return;
input.addEventListener('input', () => {
input.value = sanitizeCode(input.value);
updateEmailLoginCodeSlots();
});
input.addEventListener('focus', updateEmailLoginCodeSlots);
input.addEventListener('blur', updateEmailLoginCodeSlots);
input.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
if (sanitizeCode(input.value).length === 6 && !state.emailLoginVerifying) {
verifyEmailLoginCode();
}
}
});
}
function sanitizeCode(value) {
return String(value || '').replace(/\D/g, '').slice(0, 6);
}
function isValidEmail(value) {
const email = normalizeEmail(value);
return Boolean(email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
}
function readValidEmailLoginInput() {
const input = document.getElementById('email-login-input');
const email = normalizeEmail(input && input.value);
if (!email || !isValidEmail(email)) {
if (input) {
input.setAttribute('aria-invalid', 'true');
input.focus();
}
setAuthStatus(t(email ? 'email_invalid' : 'email_required'), true);
return '';
}
if (input) {
input.value = email;
input.removeAttribute('aria-invalid');
}
return email;
}
function openEmailLoginCodeModal(email, message, isError = false) {
state.emailLoginEmail = email;
state.emailLoginCodeModalOpen = true;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
renderEmailLoginCodeModal();
setEmailCodeStatus(message || t('email_code_sent'), isError);
window.setTimeout(() => {
const codeInput = document.getElementById('email-login-code-input');
if (state.emailLoginCodeModalOpen && codeInput) codeInput.focus();
}, 80);
}
function closeEmailLoginCodeModal() {
state.emailLoginCodeModalOpen = false;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus('');
renderEmailLoginCodeModal();
}
function renderEmailLoginCodeModal() {
const modal = document.getElementById('email-code-modal');
if (!modal) return;
if (!state.emailLoginCodeModalOpen) {
modal.classList.remove('show');
syncModalLock();
window.setTimeout(() => {
if (!state.emailLoginCodeModalOpen) modal.classList.add('hidden');
}, 180);
return;
}
const address = document.getElementById('email-code-address');
if (address) address.textContent = state.emailLoginEmail || '...';
modal.classList.remove('hidden');
syncModalLock();
applyI18n(modal);
updateEmailLoginCodeSlots();
window.requestAnimationFrame(() => modal.classList.add('show'));
}
function updateEmailLoginCodeSlots() {
const input = document.getElementById('email-login-code-input');
const value = sanitizeCode(input && input.value);
if (input && input.value !== value) input.value = value;
for (let index = 0; index < 6; index += 1) {
const slot = document.getElementById('email-code-slot-' + index);
if (!slot) continue;
const character = value[index] || '';
slot.textContent = character;
slot.classList.toggle('filled', Boolean(character));
slot.classList.toggle(
'active',
state.emailLoginCodeModalOpen
&& document.activeElement === input
&& index === Math.min(value.length, 5)
);
}
const verifyButton = document.getElementById('email-login-verify-btn');
if (verifyButton) {
verifyButton.disabled = value.length !== 6 || state.emailLoginVerifying || state.emailLoginPending;
}
const resendButton = document.getElementById('email-login-resend-btn');
if (resendButton) {
resendButton.disabled = state.emailLoginPending || state.emailLoginResending;
}
}
async function requestEmailLoginCode() {
if (state.emailLoginPending) return;
if (CFG.emailAuthEnabled === false) {
setAuthStatus(t('email_auth_disabled'), true);
return;
}
const email = readValidEmailLoginInput();
if (!email) {
return;
}
state.emailLoginPending = true;
setButtonBusy('email-login-send-btn', true);
openEmailLoginCodeModal(email, t('email_code_sending'));
setAuthStatus('');
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
const message = emailErrorMessage(e, 'email_code_send_failed');
if (e && e.error === 'invalid_email') {
closeEmailLoginCodeModal();
const input = document.getElementById('email-login-input');
if (input) input.setAttribute('aria-invalid', 'true');
setAuthStatus(message, true);
} else {
setEmailCodeStatus(message, true);
}
} finally {
state.emailLoginPending = false;
setButtonBusy('email-login-send-btn', false);
updateEmailLoginCodeSlots();
}
}
async function resendEmailLoginCode() {
if (state.emailLoginPending || state.emailLoginResending) return;
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
if (!email || !isValidEmail(email)) {
setEmailCodeStatus(t(email ? 'email_invalid' : 'email_required'), true);
return;
}
state.emailLoginResending = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('email_code_sending'));
try {
const data = await publicApi('/auth/email/request', {
email,
language: getLanguage()
});
if (!data.ok) throw data;
state.emailLoginEmail = email;
const input = document.getElementById('email-login-code-input');
if (input) input.value = '';
setEmailCodeStatus(t('email_code_sent'));
} catch (e) {
setEmailCodeStatus(emailErrorMessage(e, 'email_code_send_failed'), true);
} finally {
state.emailLoginResending = false;
updateEmailLoginCodeSlots();
}
}
async function verifyEmailLoginCode() {
const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value);
const input = document.getElementById('email-login-code-input');
const code = sanitizeCode(input && input.value);
if (!email || code.length !== 6) {
setEmailCodeStatus(t('email_code_invalid'), true);
updateEmailLoginCodeSlots();
return;
}
state.emailLoginVerifying = true;
updateEmailLoginCodeSlots();
setEmailCodeStatus(t('telegram_auth_verifying'));
try {
const data = await publicApi('/auth/email/verify', {
email,
code,
referral_code: state.referralParam || ''
});
if (!data.ok || !data.token) throw data;
setToken(data.token);
closeEmailLoginCodeModal();
await loadData();
} catch (e) {
clearToken();
setEmailCodeStatus(emailErrorMessage(e, 'email_code_invalid'), true);
} finally {
state.emailLoginVerifying = false;
updateEmailLoginCodeSlots();
}
}
function startExternalAuth(options = {}) {
const resetStatus = options.resetStatus !== false;
showLogin();
if (resetStatus) {
setAuthStatus('');
}
renderAuthMode();
}
async function loadData() {
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
setBrand();
applyI18n();
applyLegalLinks();
const plans = data.plans || [];
state.selectedPlan = getDefaultPaymentPlan(plans);
state.payment = null;
state.creatingPayment = false;
state.telegramLinkRendered = false;
render();
showApp();
}
function render() {
renderSubscription(state.data.subscription);
renderUserMenu(state.data.user || {});
renderAccount(state.data.user || {});
renderReferral(state.data.referral || {});
renderAccountMergeModal();
renderPaymentFlow();
}
function renderSubscription(sub) {
const wrap = document.getElementById('sub-status');
const text = document.getElementById('sub-status-text');
const remaining = document.getElementById('remaining');
const endCaption = document.getElementById('end-date-caption');
wrap.classList.toggle('active', Boolean(sub.active));
text.textContent = sub.active ? t('subscription_active') : t('subscription_inactive');
if (sub.active) {
const compact = formatCompactRemaining(sub.days_left, getLanguage()) || sub.remaining_text || '';
remaining.textContent = compact;
} else {
remaining.textContent = t('no_active_subscription');
}
if (sub.end_date_text) {
const template = sub.active ? 'sub_ends_template' : 'sub_ended_template';
endCaption.textContent = t(template, {date: sub.end_date_text});
endCaption.classList.remove('hidden');
} else if (sub.active) {
endCaption.textContent = t('sub_no_expiry');
endCaption.classList.remove('hidden');
} else {
endCaption.textContent = '';
endCaption.classList.add('hidden');
}
renderTraffic(sub);
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
function renderTraffic(sub) {
const bar = document.getElementById('traffic-bar');
const fill = document.getElementById('traffic-bar-fill');
const value = document.getElementById('traffic');
if (!bar || !fill || !value) return;
const limitBytes = Number(sub.traffic_limit_bytes);
const usedBytes = Number(sub.traffic_used_bytes);
const hasLimit = Number.isFinite(limitBytes) && limitBytes > 0;
const hasUsed = Number.isFinite(usedBytes) && usedBytes >= 0;
const usedText = sub.traffic_used || t('not_available');
const limitText = sub.traffic_limit || t('not_available');
bar.classList.remove('over', 'unlimited');
if (!hasLimit) {
bar.classList.add('unlimited');
fill.style.width = '100%';
value.textContent = t('traffic_unlimited');
bar.setAttribute('aria-valuenow', '0');
return;
}
const ratio = hasUsed ? usedBytes / limitBytes : 0;
const pct = Math.max(0, Math.min(100, Math.round(ratio * 100)));
fill.style.width = pct + '%';
value.textContent = usedText + ' / ' + limitText;
bar.setAttribute('aria-valuenow', String(pct));
if (hasUsed && usedBytes >= limitBytes) {
bar.classList.add('over');
}
}
function formatCompactRemaining(daysRaw, lang) {
const days = Math.max(0, Math.floor(Number(daysRaw) || 0));
if (days <= 0) return null;
if (days >= 365) {
const years = Math.round((days / 365) * 2) / 2;
return formatDecimal(years, lang) + ' ' + pluralizeUnit(years, 'year', lang);
}
if (days >= 30) {
const months = Math.round((days / 30) * 2) / 2;
return formatDecimal(months, lang) + ' ' + pluralizeUnit(months, 'month', lang);
}
return formatDecimal(days, lang) + ' ' + pluralizeUnit(days, 'day', lang);
}
function formatDecimal(value, lang) {
if (Number.isInteger(value)) return String(value);
const str = value.toFixed(1);
return lang === 'ru' ? str.replace('.', ',') : str;
}
function pluralizeUnit(value, kind, lang) {
const enMap = {year: ['year', 'years'], month: ['month', 'months'], day: ['day', 'days']};
const ruMap = {
year: ['год', 'года', 'лет'],
month: ['месяц', 'месяца', 'месяцев'],
day: ['день', 'дня', 'дней']
};
if (lang === 'en') {
return value === 1 ? enMap[kind][0] : enMap[kind][1];
}
const forms = ruMap[kind];
if (!Number.isInteger(value)) return forms[1];
const n = Math.abs(value);
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return forms[0];
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1];
return forms[2];
}
function renderUserMenu(user) {
const emailLinked = Boolean(user.email && user.email_verified);
const telegramLinked = Boolean(user.telegram_linked);
const displayName = getUserDisplayName(user);
const secondary = getUserSecondaryText(user, emailLinked, telegramLinked);
const avatarSrc = getUserAvatarSrc(user);
const avatarFallback = buildIdenticon(getUserAvatarSeed(user, emailLinked));
const chipName = document.getElementById('user-chip-name');
const chipAvatar = document.getElementById('user-chip-avatar');
const dropAvatar = document.getElementById('user-dropdown-avatar');
const dropName = document.getElementById('user-dropdown-name');
const dropSub = document.getElementById('user-dropdown-sub');
const emailStatus = document.getElementById('user-dropdown-email-status');
const telegramStatus = document.getElementById('user-dropdown-telegram-status');
chipName.textContent = displayName;
setAvatarWithFallback(chipAvatar, avatarSrc, avatarFallback, displayName);
setAvatarWithFallback(dropAvatar, avatarSrc, avatarFallback, displayName);
dropName.textContent = displayName;
dropSub.textContent = secondary;
dropSub.classList.toggle('hidden', !secondary);
emailStatus.textContent = emailLinked ? user.email : t('not_linked');
emailStatus.classList.toggle('linked', emailLinked);
emailStatus.classList.toggle('unlinked', !emailLinked);
telegramStatus.textContent = telegramLinked
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
: t('not_linked');
telegramStatus.classList.toggle('linked', telegramLinked);
telegramStatus.classList.toggle('unlinked', !telegramLinked);
}
function renderAccount(user) {
const emailLinked = Boolean(user.email && user.email_verified);
const telegramLinked = Boolean(user.telegram_linked);
const emailAuthEnabled = Boolean(state.data.settings && state.data.settings.email_auth_enabled);
const emailCtaVisible = !emailLinked && emailAuthEnabled;
const telegramCtaVisible = !telegramLinked;
const emailBox = document.getElementById('email-link-box');
const telegramBox = document.getElementById('telegram-link-box');
const linkPanel = document.getElementById('link-panel');
emailBox.classList.toggle('hidden', !emailCtaVisible);
telegramBox.classList.toggle('hidden', !telegramCtaVisible);
linkPanel.classList.toggle('hidden', !emailCtaVisible && !telegramCtaVisible);
if (!telegramLinked) {
renderTelegramLinkWidget();
} else {
state.telegramLinkRendered = false;
}
}
function getTelegramInitUser() {
if (!tg || !tg.initDataUnsafe || !tg.initDataUnsafe.user) return null;
return tg.initDataUnsafe.user;
}
function getUserAvatarSeed(user, emailLinked) {
const tgUser = getTelegramInitUser();
if (emailLinked && user && user.email) return user.email.trim().toLowerCase();
return String((user && user.id) || (user && user.telegram_id) || (tgUser && tgUser.id) || 'guest');
}
function getUserAvatarSrc(user) {
const tgUser = getTelegramInitUser();
if (tgUser && tgUser.photo_url) return tgUser.photo_url;
if (user && user.telegram_photo_url) return user.telegram_photo_url;
return '';
}
function getUserDisplayName(user) {
const tgUser = getTelegramInitUser();
if (tgUser && tgUser.first_name) return tgUser.first_name;
if (user && user.first_name) return user.first_name;
if (user && user.username) return '@' + user.username;
if (tgUser && tgUser.username) return '@' + tgUser.username;
if (user && user.email) return user.email;
return t('guest_user');
}
function getUserSecondaryText(user, emailLinked, telegramLinked) {
if (emailLinked && user.email) return user.email;
if (telegramLinked && user.telegram_id) return '#' + user.telegram_id;
return '';
}
function setAvatarWithFallback(img, avatarSrc, avatarFallback, altText) {
if (!img) return;
img.alt = altText || '';
if (!avatarSrc) {
img.onerror = null;
img.src = avatarFallback;
return;
}
img.onerror = () => {
img.onerror = null;
img.src = avatarFallback;
};
img.src = avatarSrc;
}
function buildIdenticon(seed) {
const raw = String(seed || 'guest');
let hash = 2166136261 >>> 0;
for (let i = 0; i < raw.length; i++) {
hash ^= raw.charCodeAt(i);
hash = Math.imul(hash, 16777619) >>> 0;
}
const hue = hash % 360;
const bg = 'hsl(' + hue + ', 60%, 18%)';
const fg = 'hsl(' + ((hue + 180) % 360) + ', 78%, 62%)';
let rects = '';
let bits = hash;
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 3; x++) {
const on = bits & 1;
bits = (bits >>> 1) ^ ((bits & 1) ? 0xB400 : 0);
if (!on) continue;
rects += '