1252 lines
45 KiB
JavaScript
1252 lines
45 KiB
JavaScript
// 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..."
|
||
},
|
||
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: "Поиск..."
|
||
}
|
||
};
|
||
|
||
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 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;
|
||
if (tg.initDataUnsafe?.user?.language_code === '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);
|
||
}
|
||
|
||
// Dev Mock
|
||
if (!currentState.user) {
|
||
currentState.user = { id: 583602906, first_name: 'Dev', username: 'developer', photo_url: null };
|
||
}
|
||
|
||
// 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 res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
|
||
const data = await res.json();
|
||
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
// Update Text
|
||
const statusEl = document.getElementById('dash-status');
|
||
if (statusEl) statusEl.textContent = data.status;
|
||
|
||
const limitEl = document.getElementById('dash-limit');
|
||
if (limitEl) limitEl.textContent = `${data.data_limit_gb} GB`;
|
||
|
||
const expireEl = document.getElementById('dash-expire');
|
||
if (expireEl) 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);
|