Files

1455 lines
55 KiB
JavaScript
Raw Permalink 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",
// 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.<br>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 <b>{0}</b>?",
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 сервис.<br>Быстро, Надежно, Безопасно.",
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: "Удалить промокод <b>{0}</b>?",
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 = `
<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 + ' ' + t('unit_gb') : t('plan_unlimited')}</span>
<span><i data-lucide="clock"></i> ${plan.days} ${t('unit_days')}</span>
</div>
<button class="btn-primary" onclick="initPayment('${plan.id}')">${t('btn_purchase')}</button>
`;
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 = `
<div class="sub-actions">
<button class="btn-primary" onclick="openDownloadModal()">
<i data-lucide="download"></i> ${t('btn_dl_for')} ${myOS}
</button>
<button class="btn-secondary" onclick="copyConfig()">
<i data-lucide="copy"></i> ${t('btn_copy')}
</button>
</div>
`;
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 = `
<div class="download-options">
<p class="subtitle" style="margin-bottom:20px;">${t('modal_dl_subtitle')}</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(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 = `<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(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 = `
<div class="support-form">
<p style="color:var(--md-sys-color-outline); margin-bottom:12px;">
${t('msg_support_sent')}
</p>
<textarea id="support-msg" class="glass-input" style="height:100px; padding-top:10px; resize:none;" placeholder="${t('ph_message')}"></textarea>
<button class="btn-primary" style="margin-top:16px;" onclick="sendSupport()">${t('adm_send')}</button>
</div>
`;
openModal(t('nav_support'), html);
}
function openAbout() {
openModal(t('btn_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">
${t('about_slogan')}
</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(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 = `
<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>${t('adm_no_users')}</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 || 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, `
<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 = t('title_add_days');
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">${t('subtitle_add_days')}</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')">${t('btn_apply')}</button>
</div>
`;
} else if (action === 'set_limit') {
title = t('title_set_limit');
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">${t('subtitle_set_limit')}</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')">${t('title_set_limit')}</button>
</div>
`;
} else if (action === 'set_expiry') {
title = t('title_set_exp');
html = `
<div class="modal-form">
<p class="subtitle" style="margin-bottom:12px;">${t('subtitle_set_exp')}</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')">${t('title_set_exp')}</button>
</div>
`;
} 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 = `<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;">${t('confirm_delete_promo').replace('{0}', code)}</p>
<div style="display:flex; gap:12px;">
<button class="btn-secondary" style="flex:1" onclick="closeModal()">${t('btn_back')}</button>
<button class="btn-error" style="flex:1" onclick="submitDeletePromo('${code}')">${t('adm_btn_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(t('title_new_promo'), `
<div class="promo-create-form" style="display:flex; flex-direction:column; gap:12px;">
<input type="text" id="new-promo-code" placeholder="${t('ph_code')}" 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="${t('ph_discount')}" class="glass-input">
<input type="number" id="new-promo-uses" placeholder="${t('ph_uses')}" class="glass-input">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px;">
<input type="number" id="new-promo-days" placeholder="${t('ph_days')}" class="glass-input">
<input type="number" id="new-promo-bonus" placeholder="${t('ph_bonus')}" class="glass-input">
</div>
<label class="list-item glass" style="margin:0; padding:8px 12px; cursor:pointer;">
<span style="flex:1">${t('lbl_unlim_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">${t('lbl_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;">${t('note_sticky')}</p>
<button class="btn-primary" style="margin-top:8px;" onclick="createPromo()">${t('btn_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(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", `
<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);