Files
marzban_tg_bot/web_app/static/js/app.js
2026-01-11 08:14:35 +03:00

1325 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global State
const API_BASE = '/api';
let currentState = {
user: null,
subUrl: "",
promoCode: null,
device: 'unknown',
statsInterval: null,
lang: 'en'
};
/* --- LOCALIZATION --- */
const translations = {
en: {
nav_home: "Home",
nav_shop: "Shop",
nav_config: "Config",
nav_promo: "Promo",
nav_profile: "Profile",
nav_support: "Support",
nav_admin: "Admin",
dash_welcome: "Welcome back",
dash_used: "Used Traffic",
dash_expiry: "Until Expiry",
dash_status: "Subscription Status",
btn_extend: "Extend",
btn_connect: "Connect",
shop_title: "Select Plan",
shop_subtitle: "Upgrade your experience",
btn_purchase: "Purchase",
sub_title: "Connect",
sub_subtitle: "Setup your device",
sub_link_label: "Subscription Link",
btn_copy: "Copy Link",
sub_instructions: "Detailed Instructions",
sub_instr_1: "1. Download app for your device.",
sub_instr_2: "2. Copy the link above.",
sub_instr_3: "3. Import from Clipboard.",
sub_instr_4: "4. Connect.",
btn_download: "Download",
prof_joined: "Joined",
prof_spent: "Total Spent",
prof_purchases: "Purchases",
prof_app_info: "App Info",
btn_support: "Support",
btn_about: "About",
adm_title: "Admin",
adm_stats: "Stats",
adm_users: "Users",
adm_promos: "Promos",
adm_broadcast: "Broadcast",
adm_bot_stats: "Bot Stats",
adm_total_users: "Total Users",
adm_active_subs: "Active Subs",
adm_revenue: "Revenue",
adm_server: "Server",
adm_marzban: "Marzban Users",
btn_search: "Search",
btn_back: "Back",
adm_status: "Status",
adm_expiry: "Expiry",
adm_traffic: "Traffic",
adm_btn_days: "+Days",
adm_btn_exp: "Set Exp",
adm_btn_limit: "Set GB",
adm_btn_plan: "Plan",
adm_btn_toggle: "Toggle",
adm_btn_reset: "Reset",
adm_btn_delete: "Delete Sub",
adm_create_promo: "+ Create Promo",
adm_send: "Send",
adm_broadcast_msg: "Send message to all users",
ph_message: "Message...",
ph_search: "Search...",
status_active: "Active",
status_inactive: "Inactive",
status_expired: "Expired",
status_no_sub: "No active subscription",
unit_gb: "GB"
},
ru: {
nav_home: "Главная",
nav_shop: "Магазин",
nav_config: "Подкл.",
nav_promo: "Промо",
nav_profile: "Профиль",
nav_support: "Поддержка",
nav_admin: "Админка",
dash_welcome: "С возвращением",
dash_used: "Использовано",
dash_expiry: "До истечения",
dash_status: "Статус подписки",
btn_extend: "Продлить",
btn_connect: "Подключить",
shop_title: "Выберите план",
shop_subtitle: "Улучшите свой опыт",
btn_purchase: "Купить",
sub_title: "Подключение",
sub_subtitle: "Настройка устройства",
sub_link_label: "Ссылка подписки",
btn_copy: "Скопировать",
sub_instructions: "Инструкция",
sub_instr_1: "1. Скачайте приложение.",
sub_instr_2: "2. Скопируйте ссылку выше.",
sub_instr_3: "3. Импортируйте из буфера.",
sub_instr_4: "4. Подключитесь.",
btn_download: "Скачать",
prof_joined: "Регистрация",
prof_spent: "Потрачено",
prof_purchases: "Покупок",
prof_app_info: "О приложении",
btn_support: "Поддержка",
btn_about: "О нас",
adm_title: "Админка",
adm_stats: "Статистика",
adm_users: "Юзеры",
adm_promos: "Промо",
adm_broadcast: "Рассылка",
adm_bot_stats: "Статистика бота",
adm_total_users: "Всего юзеров",
adm_active_subs: "Активных",
adm_revenue: "Выручка",
adm_server: "Сервер",
adm_marzban: "В панели",
btn_search: "Поиск",
btn_back: "Назад",
adm_status: "Статус",
adm_expiry: "Истекает",
adm_traffic: "Трафик",
adm_btn_days: "+Дни",
adm_btn_exp: "Срок",
adm_btn_limit: "Лимит",
adm_btn_plan: "План",
adm_btn_toggle: "Вкл/Выкл",
adm_btn_reset: "Сброс",
adm_btn_delete: "Удалить",
adm_create_promo: "+ Промокод",
adm_send: "Отправить",
adm_broadcast_msg: "Сообщение всем юзерам",
ph_message: "Сообщение...",
ph_search: "Поиск...",
status_active: "Активна",
status_inactive: "Неактивна",
status_expired: "Истекла",
status_no_sub: "Нет активной подписки",
unit_gb: "ГБ"
}
};
function t(key) {
const lang = currentState.lang || 'en';
return translations[lang][key] || translations['en'][key] || key;
}
function applyTranslations(container = document) {
container.querySelectorAll('[data-t]').forEach(el => {
el.textContent = t(el.dataset.t);
});
container.querySelectorAll('[data-tp]').forEach(el => {
el.placeholder = t(el.dataset.tp);
});
}
// Telegram Init
const tg = window.Telegram?.WebApp;
function showAccessDenied() {
document.body.innerHTML = '';
document.body.style.display = 'block';
const div = document.createElement('div');
div.style.cssText = 'display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; padding:24px; text-align:center; background:var(--md-sys-color-background); color:var(--md-sys-color-on-background); font-family:var(--font-brand);';
div.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--md-sys-color-error)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:24px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
<h1 style="font-size:24px; font-weight:600; margin-bottom:16px;">Access Restricted</h1>
<p style="font-size:16px; opacity:0.7; max-width:320px; margin-bottom:32px; line-height:1.5;">
This application is designed to be used exclusively via Telegram.
</p>
<a href="https://t.me/stellariseivpn_bot" style="background:var(--md-sys-color-primary); color:var(--md-sys-color-on-primary); padding:12px 24px; border-radius:100px; text-decoration:none; font-weight:500;">
Open Telegram
</a>
`;
document.body.appendChild(div);
}
function showInviteRequired() {
document.body.innerHTML = '';
document.body.style.display = 'block';
const div = document.createElement('div');
div.style.cssText = 'display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; padding:24px; text-align:center; background:var(--md-sys-color-background); color:var(--md-sys-color-on-background); font-family:var(--font-brand);';
div.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--md-sys-color-error)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:24px;"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="9" y2="9"/><path d="m9 16 2 2 4-4"/></svg>
<h1 style="font-size:24px; font-weight:600; margin-bottom:16px;">Registration Required</h1>
<p style="font-size:16px; opacity:0.7; max-width:320px; margin-bottom:32px; line-height:1.5;">
You need to activate the bot with an invite code to use this app.
</p>
<button onclick="window.Telegram?.WebApp?.close()" style="background:var(--md-sys-color-primary); color:var(--md-sys-color-on-primary); padding:12px 24px; border-radius:100px; border:none; font-size:16px; font-weight:500; cursor:pointer;">
Close App
</button>
`;
document.body.appendChild(div);
}
function initApp() {
// Enhanced OS Detection
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) currentState.device = 'ios';
else if (/android/.test(ua)) currentState.device = 'android';
else if (/macintosh|mac os x/.test(ua)) currentState.device = 'macos';
else if (/windows/.test(ua)) currentState.device = 'windows';
else if (/linux/.test(ua)) currentState.device = 'linux';
else currentState.device = 'desktop';
if (tg) {
tg.ready();
tg.expand();
try { tg.setHeaderColor('#0f172a'); } catch (e) { }
currentState.user = tg.initDataUnsafe?.user;
const userLang = (tg.initDataUnsafe?.user?.language_code || 'en').toLowerCase();
if (userLang === 'ru' || userLang.startsWith('ru')) {
currentState.lang = 'ru';
}
// Refine from TG platform
const p = tg.platform;
if (['ios'].includes(p)) currentState.device = 'ios';
else if (['macos'].includes(p)) currentState.device = 'macos';
else if (['android', 'android_x'].includes(p)) currentState.device = 'android';
// Theming
applyTheme();
tg.onEvent('themeChanged', applyTheme);
}
// Check Auth
if (!currentState.user) {
showAccessDenied();
return;
}
// Avatar Setup
updateAvatar(currentState.user);
// Initial Route
router('dashboard');
applyTranslations();
// Global Key Listeners
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
}
function applyTheme() {
const body = document.body;
const root = document.documentElement;
// Determine color scheme from Telegram or system preference
let colorScheme = 'dark'; // default
if (tg) {
colorScheme = tg.colorScheme || 'dark';
} else if (window.matchMedia) {
colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Apply theme class to body
body.classList.remove('dark', 'light');
body.classList.add(colorScheme);
// Additional TG theme customization
if (tg?.themeParams) {
const theme = tg.themeParams;
// Override primary color if provided by Telegram
if (theme.button_color) {
root.style.setProperty('--md-sys-color-primary', theme.button_color);
if (theme.button_text_color) {
root.style.setProperty('--md-sys-color-on-primary', theme.button_text_color);
}
}
// Set header color for native feel
try {
tg.setHeaderColor(colorScheme === 'dark' ? '#111318' : '#f9f9ff');
} catch (e) { }
}
console.log(`Theme applied: ${colorScheme}`);
}
function updateAvatar(user) {
const headerAvatar = document.getElementById('header-avatar');
if (!headerAvatar) return;
if (user.photo_url) {
headerAvatar.innerHTML = `<img src="${user.photo_url}" alt="Avatar">`;
} else {
headerAvatar.textContent = (user.first_name || 'U')[0].toUpperCase();
}
}
// Navigation
async function router(pageName) {
const viewContainer = document.getElementById('app-view');
const template = document.getElementById(`view-${pageName}`);
// Don't route if already on page (optional optimization, but good for UX)
// const current = document.querySelector('.nav-item.active');
// if (current && current.dataset.page === pageName) return;
// Clear existing intervals
if (currentState.statsInterval) {
clearInterval(currentState.statsInterval);
currentState.statsInterval = null;
}
// Update Nav State
document.querySelectorAll('.nav-item').forEach(item => {
if (item.dataset.page === pageName) item.classList.add('active');
else item.classList.remove('active');
});
// Animate Exit if content exists
if (viewContainer.innerHTML.trim() !== '') {
viewContainer.classList.add('page-exit');
await new Promise(r => setTimeout(r, 200));
viewContainer.classList.remove('page-exit');
}
// Update Header Title
const headerTitle = document.getElementById('header-title');
if (headerTitle) {
const titles = {
'dashboard': 'Dashboard',
'shop': 'Shop',
'subscription': 'Config',
'profile': 'Profile',
'support': 'Support',
'admin': 'Admin Control'
};
headerTitle.textContent = titles[pageName] || 'Comet';
}
// Swap View
if (template) {
viewContainer.innerHTML = '';
viewContainer.appendChild(template.content.cloneNode(true));
applyTranslations(viewContainer);
// Retrigger enter animation
viewContainer.style.animation = 'none';
viewContainer.offsetHeight; /* trigger reflow */
viewContainer.style.animation = null;
}
// Init Page Logic
if (pageName === 'dashboard') loadDashboard();
if (pageName === 'shop') loadShop();
if (pageName === 'subscription') loadSubscription();
if (pageName === 'profile') loadProfile();
if (pageName === 'admin') loadAdmin();
// Lucide Icons
try {
if (window.lucide) lucide.createIcons();
} catch (e) {
console.error("Lucide Error:", e);
}
// Smooth Scroll Top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ------ PAGE LOGIC ------
async function loadDashboard() {
document.getElementById('user-name').textContent = currentState.user.first_name;
try {
const username = currentState.user.username || '';
const lang = currentState.lang || 'en';
const res = await fetch(`${API_BASE}/user/${currentState.user.id}?username=${encodeURIComponent(username)}&lang=${lang}`);
if (res.status === 404) {
showInviteRequired();
return;
}
const data = await res.json();
if (data.error) throw new Error(data.error);
// Update Text
const statusEl = document.getElementById('dash-status');
if (statusEl) {
// Translate status from server or map common values
const statusMap = {
'Active': t('status_active'),
'Inactive': t('status_inactive'),
'Expired': t('status_expired')
};
statusEl.textContent = statusMap[data.status] || data.status;
}
const limitEl = document.getElementById('dash-limit');
if (limitEl) limitEl.textContent = `${data.data_limit_gb} ${t('unit_gb')}`;
const expireEl = document.getElementById('dash-expire');
if (expireEl) {
// Handle "No active subscription" specifically or date
if (data.expire_date === "No active subscription") {
expireEl.textContent = t('status_no_sub');
} else {
expireEl.textContent = data.expire_date;
}
}
const leftEl = document.getElementById('dash-data-left');
if (leftEl) leftEl.textContent = data.used_traffic_gb;
const daysLeftEl = document.getElementById('dash-days-left');
if (daysLeftEl) {
daysLeftEl.textContent = data.days_left > 10000 ? "∞" : data.days_left;
}
// Progress Rings
// 1. Traffic Ring
const circle = document.getElementById('data-ring');
if (circle) {
const limit = data.data_limit_gb || 0;
const used = data.used_traffic_gb || 0;
// Handle infinity (large limit)
let percent = 0;
if (limit > 900000) {
percent = 0; // Or some "full" state? 0 is safe for used/unlim
} else if (limit > 0) {
percent = Math.min((used / limit) * 100, 100);
}
circle.setAttribute('stroke-dasharray', `${percent}, 100`);
circle.style.stroke = percent > 90 ? '#f87171' : 'var(--md-sys-color-primary)';
}
// 2. Expiry Ring
const expCircle = document.getElementById('exp-ring');
if (expCircle) {
const daysLeft = data.days_left || 0;
let expPercent = 0;
if (daysLeft > 10000) {
expPercent = 0; // Infinity looks like a full/empty ring depending on logic. Let's say 0 used.
} else {
// Assuming max display represents 30 days for visual context or 100% is safe
// Let's use 30 as a standard window for the ring animation if we don't know the plan
const standardWindow = 30;
expPercent = Math.max(0, 100 - (Math.min(daysLeft, standardWindow) / standardWindow * 100));
}
expCircle.setAttribute('stroke-dasharray', `${expPercent}, 100`);
expCircle.style.stroke = expPercent > 80 ? '#f87171' : 'var(--md-sys-color-primary)';
}
currentState.subUrl = data.subscription_url;
currentState.user_full = data;
// Admin visibility
const adminNav = document.getElementById('nav-admin');
const mobileProfileBtn = document.getElementById('mobile-profile-btn');
const mobileAdminBtn = document.getElementById('mobile-admin-btn');
if (data.is_admin) {
// Desktop rail
if (adminNav) adminNav.classList.remove('hidden');
// Mobile: hide Profile, show Admin
if (mobileProfileBtn) mobileProfileBtn.classList.add('hidden');
if (mobileAdminBtn) mobileAdminBtn.classList.remove('hidden');
} else {
// Desktop rail
if (adminNav) adminNav.classList.add('hidden');
// Mobile: show Profile, hide Admin
if (mobileProfileBtn) mobileProfileBtn.classList.remove('hidden');
if (mobileAdminBtn) mobileAdminBtn.classList.add('hidden');
}
// Update user photo if not present or server provides it
if (data.photo_url) {
currentState.user.photo_url = data.photo_url;
updateAvatar(currentState.user);
}
} catch (e) {
console.error(e);
const statusEl = document.getElementById('dash-status');
if (statusEl) statusEl.textContent = 'Error';
}
}
async function loadShop() {
const container = document.getElementById('plans-container');
container.innerHTML = '<div class="loading-spinner"></div>';
try {
const res = await fetch(`${API_BASE}/plans`);
const plans = await res.json();
container.innerHTML = '';
plans.forEach(plan => {
const card = document.createElement('div');
card.className = 'glass plan-card plan-item';
card.innerHTML = `
<div class="plan-header">
<span class="plan-title">${plan.name}</span>
<span class="plan-price">${plan.price} ⭐️</span>
</div>
<div class="plan-specs">
<span><i data-lucide="database"></i> ${plan.data_limit > 0 ? plan.data_limit + ' GB' : 'Unlimited'}</span>
<span><i data-lucide="clock"></i> ${plan.days} Days</span>
</div>
<button class="btn-primary" onclick="initPayment('${plan.id}')">Purchase</button>
`;
container.appendChild(card);
});
try { if (window.lucide) lucide.createIcons(); } catch (e) { }
} catch (e) {
container.textContent = "Failed to load plans.";
}
}
async function initPayment(planId) {
if (!tg) return alert("Open in Telegram");
const btn = event.target;
const oldText = btn.innerText;
btn.innerText = 'Creating..';
btn.disabled = true;
try {
const body = {
user_id: currentState.user.id,
plan_id: planId
};
if (currentState.promoCode) {
body.promo_code = currentState.promoCode;
}
const res = await fetch(`${API_BASE}/create-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.invoice_link) {
tg.openInvoice(data.invoice_link, (status) => {
if (status === 'paid') {
showToast('Successful Payment!', 'success');
router('dashboard');
}
});
} else {
showToast('Error: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) {
showToast('Network error', 'error');
} finally {
btn.innerText = oldText;
btn.disabled = false;
}
}
async function loadSubscription() {
// Check missing sub url
if (!currentState.subUrl) {
try {
const res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
const data = await res.json();
currentState.subUrl = data.subscription_url;
} catch (e) { }
}
const url = currentState.subUrl;
const linkEl = document.getElementById('config-link');
if (url) {
if (linkEl) linkEl.textContent = url;
const qrContainer = document.getElementById('qrcode-container');
if (qrContainer) {
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: url, width: 160, height: 160,
colorDark: "#000000", colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
}
} else {
if (linkEl) linkEl.textContent = "No active subscription";
}
// Dynamic Apps based on Device
const device = currentState.device;
const appsContainer = document.getElementById('device-apps-container');
if (appsContainer) {
const osNames = { 'ios': 'iOS', 'android': 'Android', 'macos': 'macOS', 'windows': 'Windows', 'linux': 'Linux' };
const myOS = osNames[device] || 'Desktop';
appsContainer.innerHTML = `
<div class="sub-actions">
<button class="btn-primary" onclick="openDownloadModal()">
<i data-lucide="download"></i> Download for ${myOS}
</button>
<button class="btn-secondary" onclick="copyConfig()">
<i data-lucide="copy"></i> Copy Link
</button>
</div>
`;
lucide.createIcons();
}
}
function openDownloadModal() {
const device = currentState.device;
let apps = [];
if (device === 'ios') {
apps = [
{ name: 'V2Box', desc: 'Sleek & Fast', link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' },
{ name: 'Streisand', desc: 'Premium Choice', link: 'https://apps.apple.com/us/app/streisand/id6450534064' }
];
} else if (device === 'android') {
apps = [
{ name: 'v2rayNG', desc: 'Standard Client', link: 'https://play.google.com/store/apps/details?id=com.v2ray.ang' },
{ name: 'NekoBox', desc: 'Powerful & Modern', link: 'https://github.com/MatsuriDayo/NekoBoxForAndroid/releases' },
{ name: 'Hiddify', desc: 'Universal All-in-One', link: 'https://play.google.com/store/apps/details?id=app.hiddify.com' }
];
} else if (device === 'windows') {
apps = [
{ name: 'NekoRay', desc: 'Powerful & Open Source', link: 'https://github.com/MatsuriDayo/nekoray/releases' },
{ name: 'Hiddify Next', desc: 'Simple & Modern', link: 'https://github.com/hiddify/hiddify-next/releases' },
{ name: 'v2rayN', desc: 'Classic Client', link: 'https://github.com/2many986/v2rayN/releases' }
];
} else if (device === 'macos') {
apps = [
{ name: 'V2Box', desc: 'Native M1/M2 Support', link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' },
{ name: 'Hiddify Next', desc: 'Elegant Desktop Client', link: 'https://github.com/hiddify/hiddify-next/releases' }
];
} else {
// Linux or fallback
apps = [
{ name: 'NekoRay', desc: 'Best for Linux', link: 'https://github.com/MatsuriDayo/nekoray/releases' },
{ name: 'Hiddify Next', desc: 'AppImage Available', link: 'https://github.com/hiddify/hiddify-next/releases' }
];
}
let html = `
<div class="download-options">
<p class="subtitle" style="margin-bottom:20px;">Choose a client for your device:</p>
<div class="admin-list">
`;
apps.forEach(app => {
html += `
<div class="list-item glass" onclick="tg.openLink('${app.link}')" style="cursor:pointer; margin-bottom:10px;">
<div style="flex:1">
<div style="font-weight:600; font-size:16px;">${app.name}</div>
<div style="font-size:12px; opacity:0.6;">${app.desc}</div>
</div>
<i data-lucide="external-link" style="width:18px; opacity:0.5;"></i>
</div>
`;
});
html += `</div></div>`;
openModal("Download App", html);
lucide.createIcons();
}
function copyConfig() {
if (currentState.subUrl) {
navigator.clipboard.writeText(currentState.subUrl);
showToast("Link copied!", "success");
} else {
showToast("No subscription found.", "error");
}
}
function toggleAcc(header) {
const accItem = header.parentElement;
accItem.classList.toggle('open');
}
async function loadProfile() {
document.getElementById('profile-name').textContent = currentState.user.first_name;
document.getElementById('profile-id').textContent = `ID: ${currentState.user.id}`;
// Profile Avatar
const avatar = document.getElementById('profile-avatar');
if (currentState.user.photo_url) {
avatar.innerHTML = `<img src="${currentState.user.photo_url}">`;
} else {
avatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
}
// Load Stats if available
if (currentState.user_full) {
const data = currentState.user_full;
const regEl = document.getElementById('stat-reg-date');
if (regEl) regEl.textContent = data.reg_date || '...';
const spentEl = document.getElementById('stat-spent');
if (spentEl) spentEl.textContent = data.total_spent || '0';
const payEl = document.getElementById('stat-payments');
if (payEl) payEl.textContent = data.total_payments || '0';
}
}
async function checkPromo() {
const input = document.getElementById('promo-input');
const code = input.value.trim();
if (!code) return;
try {
const res = await fetch(`${API_BASE}/check-promo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
if (res.ok) {
const data = await res.json();
currentState.promoCode = data.code;
showToast(`Promo Applied! ${data.discount}% OFF.`, "success");
closeModal(); // Optional: close modal on success
} else {
showToast("Invalid Promo Code", "error");
}
} catch (e) {
showToast("Error checking promo", "error");
}
}
// ------ MODALS & SUPPORT ------
function openModal(title, contentHtml) {
const overlay = document.getElementById('modal-overlay');
const dialog = overlay.querySelector('.modal-dialog');
const mTitle = document.getElementById('modal-title');
const mBody = document.getElementById('modal-body');
mTitle.textContent = title;
mBody.innerHTML = contentHtml;
overlay.classList.remove('hidden', 'closing');
dialog.classList.remove('closing');
// Trigger reflow for animation restart
overlay.offsetHeight;
// Lucide in modal
try { if (window.lucide) lucide.createIcons(); } catch (e) { }
}
function closeModal(e) {
if (e && e.target !== e.currentTarget && !e.target.classList.contains('close-btn')) return;
const overlay = document.getElementById('modal-overlay');
const dialog = overlay.querySelector('.modal-dialog');
// Add closing class for exit animation
overlay.classList.add('closing');
dialog.classList.add('closing');
// Wait for animation to complete before hiding
setTimeout(() => {
overlay.classList.add('hidden');
overlay.classList.remove('closing');
dialog.classList.remove('closing');
}, 250);
}
function openSupport() {
const html = `
<div class="support-form">
<p style="color:var(--md-sys-color-outline); margin-bottom:12px;">
Describe your issue. We will contact you via Telegram.
</p>
<textarea id="support-msg" class="glass-input" style="height:100px; padding-top:10px; resize:none;" placeholder="Your message..."></textarea>
<button class="btn-primary" style="margin-top:16px;" onclick="sendSupport()">Send Message</button>
</div>
`;
openModal("Support", html);
}
function openAbout() {
openModal("About", `
<div style="text-align:center">
<i data-lucide="rocket" style="width:48px;height:48px;color:var(--md-sys-color-primary)"></i>
<h3 style="margin-top:12px">Stellarisei VPN v1.2</h3>
<p style="color:var(--md-sys-color-outline); margin-top:8px">
Premium V2Ray service.<br>Fast, Secure, Reliable.
</p>
<p style="margin-top:16px; font-size:12px; opacity:0.5">© 2026 Stellarisei</p>
</div>
`);
}
async function sendSupport() {
const msg = document.getElementById('support-msg').value;
if (!msg.trim()) return;
// Show loading
const btn = event.target;
const oldText = btn.innerText;
btn.innerText = "Sending...";
btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/support`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: currentState.user.id,
username: currentState.user.username || "Unknown",
message: msg
})
});
if (res.ok) {
closeModal();
showToast("Message sent! Admin will contact you.", "success");
} else {
showToast("Failed to send.", "error");
}
} catch (e) {
showToast("Error sending message.", "error");
} finally {
if (btn) {
btn.innerText = oldText;
btn.disabled = false;
}
}
}
// --- ADMIN LOGIC ---
async function adminInit() {
// Hidden tab check already in loadDashboard
}
async function loadAdmin() {
// Micro-delay to ensure DOM is ready after router swap
setTimeout(() => {
const adminContent = document.getElementById('admin-content');
if (!adminContent) return;
adminTab('stats');
}, 0);
}
async function adminTab(tabName) {
// UI update
document.querySelectorAll('.tab-item').forEach(btn => {
btn.classList.toggle('active', btn.textContent.trim().toLowerCase() === tabName.toLowerCase());
});
const container = document.getElementById('admin-content');
const template = document.getElementById(`admin-${tabName}`);
if (!template) return;
// Exit animation
container.classList.add('tab-exit');
// Wait for exit animation
await new Promise(resolve => setTimeout(resolve, 150));
container.innerHTML = template.innerHTML;
// Entry animation
container.classList.remove('tab-exit');
container.classList.add('tab-enter');
// Remove entry class after animation
setTimeout(() => container.classList.remove('tab-enter'), 350);
lucide.createIcons();
// Clear any previous interval when switching tabs
if (currentState.statsInterval) {
clearInterval(currentState.statsInterval);
currentState.statsInterval = null;
}
if (tabName === 'stats') {
refreshAdminStats();
currentState.statsInterval = setInterval(refreshAdminStats, 1000);
} else if (tabName === 'users') {
adminSearchUsers();
} else if (tabName === 'promos') {
loadAdminPromos();
}
}
async function refreshAdminStats() {
try {
const res = await fetch(`${API_BASE}/admin/stats?user_id=${currentState.user.id}`);
const data = await res.json();
if (data.error) return;
const elTotal = document.getElementById('adm-total-users');
const elActive = document.getElementById('adm-active-subs');
const elRevenue = document.getElementById('adm-revenue');
const elCpu = document.getElementById('adm-cpu');
const elRam = document.getElementById('adm-ram');
const elMarz = document.getElementById('adm-active-marz');
if (elTotal) elTotal.textContent = data.bot.total;
if (elActive) elActive.textContent = data.bot.active;
if (elRevenue) elRevenue.textContent = `${data.bot.revenue} ⭐️`;
if (elCpu) elCpu.textContent = `${data.server.cpu}%`;
if (elRam) elRam.textContent = `${data.server.ram_used} / ${data.server.ram_total} GB`;
if (elMarz) elMarz.textContent = data.server.active_users;
} catch (e) {
console.error("Stats fetch error:", e);
}
}
async function adminSearchUsers() {
const searchInput = document.getElementById('admin-user-search');
if (searchInput && !searchInput.dataset.listener) {
searchInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') adminSearchUsers();
});
searchInput.dataset.listener = 'true';
}
const query = searchInput ? searchInput.value : '';
const res = await fetch(`${API_BASE}/admin/users?user_id=${currentState.user.id}&query=${query}`);
const users = await res.json();
const list = document.getElementById('admin-users-list');
list.innerHTML = '';
if (users.length === 0) {
list.innerHTML = `
<div class="card glass" style="text-align:center; padding:24px; opacity:0.6;">
<i data-lucide="search-x" style="width:40px; height:40px; margin-bottom:12px;"></i>
<p>No users found</p>
</div>
`;
lucide.createIcons();
return;
}
users.forEach(u => {
const div = document.createElement('div');
div.className = 'list-item glass';
div.style.marginBottom = '8px';
div.style.padding = '8px 16px';
div.innerHTML = `
<div class="avatar-xs" style="margin-right:12px;">
<img src="${u.photo_url || ''}" onerror="this.style.display='none'; this.parentElement.textContent='${(u.username || 'U')[0].toUpperCase()}'">
</div>
<div>
<div style="font-weight:600">${u.username || 'User'}</div>
<div style="font-size:12px;opacity:0.6">${u.user_id}</div>
</div>
<i data-lucide="chevron-right" style="margin-left:auto"></i>
`;
div.onclick = () => showAdminUserDetail(u.user_id);
list.appendChild(div);
});
lucide.createIcons();
}
async function showAdminUserDetail(targetId) {
const res = await fetch(`${API_BASE}/admin/user/${targetId}?user_id=${currentState.user.id}`);
const data = await res.json();
currentState.admin_target = targetId;
const container = document.getElementById('admin-content');
container.innerHTML = document.getElementById('admin-user-detail').innerHTML;
document.getElementById('adm-user-name').textContent = data.user.username || 'User';
document.getElementById('adm-user-id').textContent = `ID: ${data.user.user_id}`;
// Detail Avatar
const detailAvatar = document.getElementById('adm-user-avatar');
if (detailAvatar) {
const photoUrl = `/api/user-photo/${data.user.user_id}`;
detailAvatar.innerHTML = `<img src="${photoUrl}" onerror="this.style.display='none'; this.parentElement.textContent='${(data.user.username || 'U')[0].toUpperCase()}'">`;
}
const m = data.marzban || {};
document.getElementById('adm-user-status').textContent = m.status || 'Inactive';
const subUntil = data.user.subscription_until ? new Date(data.user.subscription_until) : null;
let expStr = 'None';
if (subUntil) {
expStr = subUntil.getFullYear() >= 2099 ? '∞' : subUntil.toLocaleDateString();
}
document.getElementById('adm-user-expire').textContent = expStr;
const used = (m.used_traffic / (1024 ** 3)).toFixed(2);
const limitGB = m.data_limit / (1024 ** 3);
const limitStr = limitGB > 900000 ? '∞' : limitGB.toFixed(2);
document.getElementById('adm-user-traffic').textContent = `${used} / ${limitStr} GB`;
lucide.createIcons();
}
async function adminUserAction(action) {
if (action === 'toggle_status' || action === 'reset_traffic' || action === 'delete_sub') {
const config = {
'toggle_status': { title: 'Toggle Status', msg: 'Change user active/disabled state?', btnClass: 'btn-primary' },
'reset_traffic': { title: 'Reset Traffic', msg: 'Reset used traffic to zero?', btnClass: 'btn-primary' },
'delete_sub': { title: 'Delete Subscription', msg: 'Are you sure? User will lose VPN access immediately.', btnClass: 'btn-error' }
};
const cfg = config[action];
openModal(cfg.title, `
<div class="confirm-modal">
<p class="subtitle" style="margin-bottom:24px;">${cfg.msg}</p>
<div style="display:flex; gap:12px;">
<button class="btn-secondary" style="flex:1" onclick="closeModal()">Cancel</button>
<button class="${cfg.btnClass}" style="flex:1" onclick="submitConfirmAction('${action}')">Confirm</button>
</div>
</div>
`);
return;
}
let title = "";
let html = "";
if (action === 'add_days') {
title = "Add Days";
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">Enter days to ADD (negative to subtract):</p>
<input type="number" id="adm-action-val" class="glass-input" value="30" style="text-align:center;">
<button class="btn-primary" style="margin-top:20px;" onclick="submitAdminAction('add_days')">Apply</button>
</div>
`;
} else if (action === 'set_limit') {
title = "Set Traffic Limit";
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">Enter new limit in GB (0 for ∞):</p>
<input type="number" id="adm-action-val" class="glass-input" value="50" style="text-align:center;">
<button class="btn-primary" style="margin-top:20px;" onclick="submitAdminAction('set_limit')">Set Limit</button>
</div>
`;
} else if (action === 'set_expiry') {
title = "Set Expiration";
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">Days from NOW (0 = expire, 36500 = ∞):</p>
<input type="number" id="adm-action-val" class="glass-input" value="30" style="text-align:center;">
<button class="btn-primary" style="margin-top:20px;" onclick="submitAdminAction('set_expiry')">Set Expiry</button>
</div>
`;
} else if (action === 'set_plan') {
title = "Select Plan";
const res = await fetch(`${API_BASE}/admin/plans_full?user_id=${currentState.user.id}`);
const allPlans = await res.json();
html = `<div class="admin-list" style="max-height:300px; overflow-y:auto;">`;
allPlans.forEach(p => {
html += `
<div class="list-item glass" onclick="submitAdminPlan('${p.id}')" style="cursor:pointer; margin-bottom:8px;">
<div style="flex:1">
<div style="font-weight:600">${p.name}</div>
<div style="font-size:12px; opacity:0.6">${p.days}d / ${p.data_limit || '∞'}GB</div>
</div>
<i data-lucide="chevron-right" style="width:16px; opacity:0.4;"></i>
</div>
`;
});
html += `</div>`;
}
openModal(title, html);
lucide.createIcons();
}
async function submitAdminAction(action) {
const input = document.getElementById('adm-action-val');
if (!input) return;
const value = input.value;
let payload = { user_id: currentState.user.id, action: action };
if (action === 'add_days' || action === 'set_expiry') payload.days = parseInt(value);
if (action === 'set_limit') payload.limit_gb = parseFloat(value);
const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeModal();
showToast("Success!");
showAdminUserDetail(currentState.admin_target);
} else {
showToast("Failed", "error");
}
}
async function submitAdminPlan(planId) {
const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: currentState.user.id,
action: 'set_plan',
plan_id: planId
})
});
if (res.ok) {
closeModal();
showToast("Plan Applied!");
showAdminUserDetail(currentState.admin_target);
} else {
showToast("Failed", "error");
}
}
async function submitConfirmAction(action) {
const res = await fetch(`${API_BASE}/admin/user/${currentState.admin_target}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: currentState.user.id, action: action })
});
if (res.ok) {
closeModal();
showToast("Success!");
showAdminUserDetail(currentState.admin_target);
} else {
showToast("Action failed", "error");
}
}
async function loadAdminPromos() {
const res = await fetch(`${API_BASE}/admin/promos?user_id=${currentState.user.id}`);
const promos = await res.json();
const list = document.getElementById('admin-promos-list');
list.innerHTML = '';
promos.forEach(p => {
const div = document.createElement('div');
div.className = 'list-item glass';
div.style.marginBottom = '8px';
const expDate = p.expires_at ? new Date(p.expires_at).toLocaleDateString() : 'Never';
div.innerHTML = `
<div style="flex:1">
<div style="font-weight:700">CODE: ${p.code}</div>
<div style="font-size:12px;opacity:0.6">${p.discount}% | Uses: ${p.uses_left} | Exp: ${expDate}</div>
</div>
<button class="btn-icon" onclick="deleteAdminPromo('${p.code}')" style="color:var(--md-sys-color-error); background:none; border:none; padding:8px;">
<i data-lucide="trash-2"></i>
</button>
`;
list.appendChild(div);
});
lucide.createIcons();
}
async function deleteAdminPromo(code) {
openModal("Delete Promo", `
<div class="confirm-modal">
<p class="subtitle" style="margin-bottom:24px;">Are you sure you want to delete promo code <b>${code}</b>?</p>
<div style="display:flex; gap:12px;">
<button class="btn-secondary" style="flex:1" onclick="closeModal()">Cancel</button>
<button class="btn-error" style="flex:1" onclick="submitDeletePromo('${code}')">Delete</button>
</div>
</div>
`);
}
async function submitDeletePromo(code) {
const res = await fetch(`${API_BASE}/admin/promo/${code}?user_id=${currentState.user.id}`, {
method: 'DELETE'
});
if (res.ok) {
closeModal();
showToast("Promo deleted!");
loadAdminPromos();
} else {
showToast("Failed to delete", "error");
}
}
function openCreatePromoModal() {
openModal("New Promo", `
<div class="promo-create-form" style="display:flex; flex-direction:column; gap:12px;">
<input type="text" id="new-promo-code" placeholder="CODE (e.g. SUMMER2024)" class="glass-input" style="text-transform:uppercase;">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px;">
<input type="number" id="new-promo-discount" placeholder="Discount %" class="glass-input">
<input type="number" id="new-promo-uses" placeholder="Uses Limit" class="glass-input">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px;">
<input type="number" id="new-promo-days" placeholder="Validity Days" class="glass-input">
<input type="number" id="new-promo-bonus" placeholder="Bonus Days" class="glass-input">
</div>
<label class="list-item glass" style="margin:0; padding:8px 12px; cursor:pointer;">
<span style="flex:1">Unlimited Usage</span>
<input type="checkbox" id="new-promo-unlim" style="width:20px; height:20px;">
</label>
<label class="list-item glass" style="margin:0; padding:8px 12px; cursor:pointer;">
<span style="flex:1">Permanent (Sticky)</span>
<input type="checkbox" id="new-promo-sticky" style="width:20px; height:20px;">
</label>
<p style="font-size:11px; opacity:0.6; padding:0 4px;">* Sticky promo locks the discount for the user forever.</p>
<button class="btn-primary" style="margin-top:8px;" onclick="createPromo()">Create Promo</button>
</div>
`);
}
async function createPromo() {
const payload = {
user_id: currentState.user.id,
code: document.getElementById('new-promo-code').value.toUpperCase(),
discount: parseInt(document.getElementById('new-promo-discount').value || 0),
uses: parseInt(document.getElementById('new-promo-uses').value || 1),
days: parseInt(document.getElementById('new-promo-days').value || 0),
bonus_days: parseInt(document.getElementById('new-promo-bonus').value || 0),
is_unlimited: document.getElementById('new-promo-unlim').checked,
is_sticky: document.getElementById('new-promo-sticky').checked
};
const res = await fetch(`${API_BASE}/admin/promos/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeModal();
showToast("Promo Created!");
adminTab('promos');
} else {
showToast("Error creating promo", "error");
}
}
async function sendBroadcast() {
const msg = document.getElementById('broadcast-msg').value;
if (!msg) return;
if (!confirm("Send this message to ALL users?")) return;
const res = await fetch(`${API_BASE}/admin/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: currentState.user.id, message: msg })
});
const data = await res.json();
showToast(`Broadcast sent to ${data.sent} users!`);
}
function openPromoModal() {
openModal("Promo Code", `
<div class="promo-modal-content">
<p style="color:var(--md-sys-color-outline); margin-bottom:16px;">
Enter your promo code to get discounts or bonus days.
</p>
<input type="text" id="promo-input" placeholder="ENTER CODE" class="glass-input" style="text-align:center; font-weight:700; letter-spacing:2px;">
<button class="btn-primary" style="margin-top:24px;" onclick="checkPromo()">Apply Promo</button>
</div>
`);
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
'info': 'info',
'success': 'check-circle',
'error': 'alert-circle'
};
toast.innerHTML = `
<i data-lucide="${icons[type] || 'info'}" class="${type}"></i>
<span>${message}</span>
`;
container.appendChild(toast);
if (window.lucide) lucide.createIcons();
// Remove after delay
setTimeout(() => {
toast.classList.add('toast-exit');
// Wait for animation
setTimeout(() => {
toast.remove();
}, 400);
}, 3000);
}
// App Start
document.addEventListener('DOMContentLoaded', initApp);