// 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",
// New Keys
err_plans_load: "Failed to load plans.",
err_open_tg: "Open in Telegram",
btn_creating: "Creating..",
plan_unlimited: "Unlimited",
unit_days: "Days",
msg_paid: "Successful Payment!",
err_network: "Network error",
msg_link_copied: "Link copied!",
err_no_sub: "No subscription found.",
modal_dl_title: "Download App",
modal_dl_subtitle: "Choose a client for your device:",
btn_dl_for: "Download for",
msg_promo_applied: "Promo Applied! {0}% OFF.",
err_promo_invalid: "Invalid Promo Code",
err_promo_check: "Error checking promo",
about_slogan: "Premium V2Ray service.
Fast, Secure, Reliable.",
msg_support_sent: "Message sent! Admin will contact you.",
err_send_fail: "Failed to send.",
adm_no_users: "No users found",
confirm_toggle: "Change user active/disabled state?",
confirm_reset: "Reset used traffic to zero?",
confirm_delete_sub: "Are you sure? User will lose VPN access immediately.",
title_add_days: "Add Days",
subtitle_add_days: "Enter days to ADD (negative to subtract):",
btn_apply: "Apply",
title_set_limit: "Set Traffic Limit",
subtitle_set_limit: "Enter new limit in GB (0 for ∞):",
title_set_exp: "Set Expiration",
subtitle_set_exp: "Days from NOW (0 = expire, 36500 = ∞):",
confirm_broadcast: "Send this message to ALL users?",
msg_broadcast_sent: "Broadcast sent to {0} users!",
title_new_promo: "New Promo",
ph_code: "CODE (e.g. SUMMER2024)",
ph_discount: "Discount %",
ph_uses: "Uses Limit",
ph_days: "Validity Days",
ph_bonus: "Bonus Days",
lbl_unlim_usage: "Unlimited Usage",
lbl_sticky: "Permanent (Sticky)",
note_sticky: "* Sticky promo locks the discount for the user forever.",
btn_create_promo: "Create Promo",
msg_promo_created: "Promo Created!",
err_promo_create: "Error creating promo",
msg_promo_deleted: "Promo deleted!",
confirm_delete_promo: "Are you sure you want to delete promo code {0}?",
app_desc_sleek: "Sleek & Fast",
app_desc_premium: "Premium Choice",
app_desc_standard: "Standard Client",
app_desc_modern: "Powerful & Modern",
app_desc_aio: "Universal All-in-One",
app_desc_opensource: "Powerful & Open Source",
app_desc_simple: "Simple & Modern",
app_desc_classic: "Classic Client",
app_desc_native: "Native M1/M2 Support",
app_desc_elegant: "Elegant Desktop Client"
},
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: "ГБ",
// New Keys RU
err_plans_load: "Не удалось загрузить планы.",
err_open_tg: "Откройте в Telegram",
btn_creating: "Создание..",
plan_unlimited: "Безлимит",
unit_days: "Дней",
msg_paid: "Успешная оплата!",
err_network: "Ошибка сети",
msg_link_copied: "Ссылка скопирована!",
err_no_sub: "Подписка не найдена.",
modal_dl_title: "Скачать приложение",
modal_dl_subtitle: "Выберите клиент для устройства:",
btn_dl_for: "Скачать для",
msg_promo_applied: "Промо применено! Скидка {0}%.",
err_promo_invalid: "Неверный промокод",
err_promo_check: "Ошибка проверки промо",
about_slogan: "Премиум V2Ray сервис.
Быстро, Надежно, Безопасно.",
msg_support_sent: "Отправлено! Админ свяжется с вами.",
err_send_fail: "Ошибка отправки.",
adm_no_users: "Пользователи не найдены",
confirm_toggle: "Изменить статус (вкл/выкл)?",
confirm_reset: "Сбросить трафик в ноль?",
confirm_delete_sub: "Удалить подписку? Пользователь потеряет доступ.",
title_add_days: "Добавить дни",
subtitle_add_days: "Дней для добавления (отрицат. = убавить):",
btn_apply: "Применить",
title_set_limit: "Установить лимит",
subtitle_set_limit: "Лимит в ГБ (0 = безлимит):",
title_set_exp: "Срок действия",
subtitle_set_exp: "Дней от СЕЙЧАС (0 = истекло, 36500 = ∞):",
confirm_broadcast: "Отправить сообщение ВСЕМ пользователям?",
msg_broadcast_sent: "Рассылка отправлена {0} пользователям!",
title_new_promo: "Новый Промокод",
ph_code: "КОД (напр. SUMMER2024)",
ph_discount: "Скидка %",
ph_uses: "Лимит испол.",
ph_days: "Дней действия",
ph_bonus: "Бонусные дни",
lbl_unlim_usage: "Безлимит использований",
lbl_sticky: "Перманентный (Sticky)",
note_sticky: "* Sticky закрепляет скидку за пользователем навсегда.",
btn_create_promo: "Создать",
msg_promo_created: "Промокод создан!",
err_promo_create: "Ошибка создания",
msg_promo_deleted: "Промокод удален!",
confirm_delete_promo: "Удалить промокод {0}?",
app_desc_sleek: "Быстрый и удобный",
app_desc_premium: "Премиум выбор",
app_desc_standard: "Стандартный клиент",
app_desc_modern: "Мощный и современный",
app_desc_aio: "Универсальный комбайн",
app_desc_opensource: "Мощный Open Source",
app_desc_simple: "Простой и современный",
app_desc_classic: "Классика",
app_desc_native: "Нативная поддержка M1/M2",
app_desc_elegant: "Элегантный клиент"
}
};
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 = `
Access Restricted
This application is designed to be used exclusively via Telegram.
Open Telegram
`;
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 = `
Registration Required
You need to activate the bot with an invite code to use this app.
`;
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 = `
`;
} 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 = '';
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 = `
${plan.data_limit > 0 ? plan.data_limit + ' ' + t('unit_gb') : t('plan_unlimited')}
${plan.days} ${t('unit_days')}
`;
container.appendChild(card);
});
try { if (window.lucide) lucide.createIcons(); } catch (e) { }
} catch (e) {
container.textContent = t('err_plans_load');
}
}
async function initPayment(planId) {
if (!tg) return alert(t('err_open_tg'));
const btn = event.target;
const oldText = btn.innerText;
btn.innerText = t('btn_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(t('msg_paid'), 'success');
router('dashboard');
}
});
} else {
showToast('Error: ' + (data.error || 'Unknown'), 'error');
}
} catch (e) {
showToast(t('err_network'), '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 = t('status_no_sub');
}
// 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 = `
`;
lucide.createIcons();
}
}
function openDownloadModal() {
const device = currentState.device;
let apps = [];
if (device === 'ios') {
apps = [
{ name: 'V2Box', desc: t('app_desc_sleek'), link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' },
{ name: 'Streisand', desc: t('app_desc_premium'), link: 'https://apps.apple.com/us/app/streisand/id6450534064' }
];
} else if (device === 'android') {
apps = [
{ name: 'v2rayNG', desc: t('app_desc_standard'), link: 'https://play.google.com/store/apps/details?id=com.v2ray.ang' },
{ name: 'NekoBox', desc: t('app_desc_modern'), link: 'https://github.com/MatsuriDayo/NekoBoxForAndroid/releases' },
{ name: 'Hiddify', desc: t('app_desc_aio'), link: 'https://play.google.com/store/apps/details?id=app.hiddify.com' }
];
} else if (device === 'windows') {
apps = [
{ name: 'NekoRay', desc: t('app_desc_opensource'), link: 'https://github.com/MatsuriDayo/nekoray/releases' },
{ name: 'Hiddify Next', desc: t('app_desc_simple'), link: 'https://github.com/hiddify/hiddify-next/releases' },
{ name: 'v2rayN', desc: t('app_desc_classic'), link: 'https://github.com/2many986/v2rayN/releases' }
];
} else if (device === 'macos') {
apps = [
{ name: 'V2Box', desc: t('app_desc_native'), link: 'https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690' },
{ name: 'Hiddify Next', desc: t('app_desc_elegant'), 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 = `
${t('modal_dl_subtitle')}
`;
apps.forEach(app => {
html += `
`;
});
html += `
`;
openModal(t('modal_dl_title'), html);
lucide.createIcons();
}
function copyConfig() {
if (currentState.subUrl) {
navigator.clipboard.writeText(currentState.subUrl);
showToast(t('msg_link_copied'), "success");
} else {
showToast(t('err_no_sub'), "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 = `
`;
} 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(t('msg_promo_applied').replace('{0}', data.discount), "success");
closeModal(); // Optional: close modal on success
} else {
showToast(t('err_promo_invalid'), "error");
}
} catch (e) {
showToast(t('err_promo_check'), "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 = `
`;
openModal(t('nav_support'), html);
}
function openAbout() {
openModal(t('btn_about'), `
Stellarisei VPN v1.2
${t('about_slogan')}
© 2026 Stellarisei
`);
}
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(t('msg_support_sent'), "success");
} else {
showToast(t('err_send_fail'), "error");
}
} catch (e) {
showToast(t('err_network'), "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 = `
`;
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 = `
${u.username || 'User'}
${u.user_id}
`;
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 = `
`;
}
const m = data.marzban || {};
document.getElementById('adm-user-status').textContent = m.status || t('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: t('adm_btn_toggle'), msg: t('confirm_toggle'), btnClass: 'btn-primary' },
'reset_traffic': { title: t('adm_btn_reset'), msg: t('confirm_reset'), btnClass: 'btn-primary' },
'delete_sub': { title: t('adm_btn_delete'), msg: t('confirm_delete_sub'), btnClass: 'btn-error' }
};
const cfg = config[action];
openModal(cfg.title, `
${cfg.msg}
`);
return;
}
let title = "";
let html = "";
if (action === 'add_days') {
title = t('title_add_days');
html = `
`;
} else if (action === 'set_limit') {
title = t('title_set_limit');
html = `
`;
} else if (action === 'set_expiry') {
title = t('title_set_exp');
html = `
`;
} else if (action === 'set_plan') {
title = t('shop_title');
const res = await fetch(`${API_BASE}/admin/plans_full?user_id=${currentState.user.id}`);
const allPlans = await res.json();
html = ``;
allPlans.forEach(p => {
html += `
${p.name}
${p.days}d / ${p.data_limit || '∞'}GB
`;
});
html += `
`;
}
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 = `
CODE: ${p.code}
${p.discount}% | Uses: ${p.uses_left} | Exp: ${expDate}
`;
list.appendChild(div);
});
lucide.createIcons();
}
async function deleteAdminPromo(code) {
openModal("Delete Promo", `
${t('confirm_delete_promo').replace('{0}', code)}
`);
}
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(t('title_new_promo'), `
`);
}
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(t('confirm_broadcast'))) 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(t('msg_broadcast_sent').replace('{0}', data.sent));
}
function openPromoModal() {
openModal("Promo Code", `
`);
}
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 = `
${message}
`;
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);