Update WebApp

This commit is contained in:
2026-01-11 07:07:32 +03:00
parent 32d0f98a6e
commit 2b68dbac20
17 changed files with 3501 additions and 824 deletions

View File

@@ -13,11 +13,12 @@ CONFIG = {
"PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""),
"BASE_URL": os.getenv("BASE_URL"), # Внешний домен для VPN (панель Marzban)
"WEB_APP_URL": os.getenv("WEB_APP_URL"), # URL для веб-приложения (Mini App)
"WEB_APP_PORT": int(os.getenv("WEB_APP_PORT", 8000)),
"WEB_APP_PORT": int(os.getenv("WEB_APP_PORT", 8888)),
}
PLANS = {
"month_1": {"name": "1 месяц", "days": 30, "price": 100, "data_limit": 50},
"month_3": {"name": "3 месяца", "days": 90, "price": 270, "data_limit": 150},
"month_6": {"name": "6 месяцев", "days": 180, "price": 500, "data_limit": 300},
"lite": {"name": "Lite (50 GB)", "days": 30, "price": 100, "data_limit": 50},
"medium": {"name": "Medium (150 GB)", "days": 90, "price": 270, "data_limit": 150},
"heavy": {"name": "Heavy (600 GB)", "days": 365, "price": 1000, "data_limit": 600},
"unlimited": {"name": "Безлимитище", "days": 30, "price": 2500, "data_limit": 0},
}

View File

@@ -180,6 +180,14 @@ class Database:
now = datetime.now()
await self.execute("UPDATE users SET last_traffic_reset = $1 WHERE user_id = $2", now, user_id)
async def get_referrals_count(self, user_id: int):
return await self.fetchval("SELECT COUNT(*) FROM users WHERE invited_by = $1", user_id) or 0
async def get_user_payments_info(self, user_id: int):
total_amount = await self.fetchval("SELECT SUM(amount) FROM payments WHERE user_id = $1", user_id) or 0
total_count = await self.fetchval("SELECT COUNT(*) FROM payments WHERE user_id = $1", user_id) or 0
return {"total_amount": total_amount, "total_count": total_count}
async def remove_subscription(self, user_id: int):
await self.execute("UPDATE users SET subscription_until = NULL WHERE user_id = $1", user_id)
@@ -237,6 +245,9 @@ class Database:
code, discount, uses, created_by, expires_at, is_unlimited, bonus_days, is_sticky
)
async def delete_promo_code(self, code: str):
await self.execute("DELETE FROM promo_codes WHERE code = $1", code)
async def set_user_discount(self, user_id: int, discount: int):
await self.execute("UPDATE users SET personal_discount = $1 WHERE user_id = $2", discount, user_id)
@@ -281,6 +292,7 @@ class Database:
async def decrement_promo_usage(self, code: str):
await self.execute("UPDATE promo_codes SET uses_left = uses_left - 1 WHERE code = $1", code)
await self.execute("DELETE FROM promo_codes WHERE code = $1 AND uses_left <= 0", code)
async def add_payment(self, user_id: int, plan: str, amount: int, promo_code: str = None):
await self.execute(

View File

@@ -148,42 +148,47 @@ async def admin_promos(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
promos = await db.get_active_promos() # Only valid ones
text = "🏷 <b>Активные промокоды:</b>\n\n"
if not promos:
text = "Нет активных промокодов."
text = "🏷 <b>Управление промокодами:</b>\n\n"
kb_buttons = []
now = datetime.now()
for p in promos:
# Обработка даты (SQLite возвращает строку)
exp_val = p['expires_at']
exp_dt = None
if exp_val:
if isinstance(exp_val, str):
try:
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f')
try: exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S')
except:
pass
try: exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f')
except: pass
elif isinstance(exp_val, datetime):
exp_dt = exp_val
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else ""
# Filter expired
if exp_dt and exp_dt < now:
continue
# Получаем значения по ключам (не через get)
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else ""
is_unl = p['is_unlimited']
type_str = " (VIP)" if is_unl else f" (-{p['discount']}%)"
type_str = " (VIP)" if is_unl else f" (-{p['discount']}%)"
text += (
f"🔹 <code>{p['code']}</code>{type_str}\n"
f" Осталось: {p['uses_left']} | До: {exp_str}\n"
f" Осталось: {p['uses_left']} | До: {exp_str}\n\n"
)
# Add delete button for each promo
kb_buttons.append([InlineKeyboardButton(text=f"❌ Удалить {p['code']}", callback_data=f"admin_promo_del_{p['code']}")])
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Создать промокод", callback_data="admin_create_promo")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
])
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
kb_buttons.append([InlineKeyboardButton(text=" Создать промокод", callback_data="admin_create_promo")])
kb_buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")])
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_buttons), parse_mode="HTML")
@router.callback_query(F.data.startswith("admin_promo_del_"))
async def admin_delete_promo_bot(callback: CallbackQuery):
code = callback.data.replace("admin_promo_del_", "")
await db.delete_promo_code(code)
await callback.answer(f"✅ Промокод {code} удален")
await admin_promos(callback)
@router.callback_query(F.data == "admin_create_promo")
async def start_create_promo(callback: CallbackQuery, state: FSMContext):
@@ -510,16 +515,18 @@ async def show_user_panel(message_or_call, user_id):
rows = [
[InlineKeyboardButton(text=" Продлить", callback_data=f"adm_usr_add_{user_id}"),
InlineKeyboardButton(text="✏️ Лимит", callback_data=f"adm_usr_gb_{user_id}")],
[status_btn,
InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"adm_usr_reset_{user_id}")]
InlineKeyboardButton(text="⏳ Уст. срок", callback_data=f"adm_usr_set_{user_id}")],
[InlineKeyboardButton(text="✏️ Лимит ГБ", callback_data=f"adm_usr_gb_{user_id}"),
InlineKeyboardButton(text="🔄 Сброс", callback_data=f"adm_usr_reset_{user_id}")],
[InlineKeyboardButton(text="📋 План (Admin)", callback_data=f"adm_usr_plan_{user_id}"),
InlineKeyboardButton(text="✉️ Написать", callback_data=f"adm_usr_msg_{user_id}")],
[status_btn]
]
# Только если есть активная дата подписки
if sub_until and isinstance(sub_until, datetime):
rows.append([InlineKeyboardButton(text="❌ Удалить подписку", callback_data=f"adm_usr_delsub_{user_id}")])
rows.append([InlineKeyboardButton(text="✉️ Сообщение", callback_data=f"adm_usr_msg_{user_id}")])
rows.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_users_list")])
kb = InlineKeyboardMarkup(inline_keyboard=rows)
@@ -590,6 +597,109 @@ async def adm_usr_add_process(message: Message, state: FSMContext):
except ValueError:
await message.answer("Ошибка. Введите целое число.")
# Set Fixed Expiry
@router.callback_query(F.data.startswith("adm_usr_set_"))
async def adm_usr_set_start(callback: CallbackQuery, state: FSMContext):
user_id = int(callback.data.split("_")[3])
await state.update_data(target_user_id=user_id)
await callback.message.edit_text(
"Введите общее количество дней подписки от ТЕКУЩЕГО момента:\n"
"0 - Истечет сразу\n"
"36500 - Вечная подписка",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"adm_sel_{user_id}")]])
)
await state.set_state(AdminUserStates.waiting_for_fixed_days)
@router.message(AdminUserStates.waiting_for_fixed_days)
async def adm_usr_set_process(message: Message, state: FSMContext):
try:
days = int(message.text)
data = await state.get_data()
user_id = data['target_user_id']
if days > 10000:
new_date = datetime(2099, 12, 31)
else:
new_date = datetime.now() + timedelta(days=days)
await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, user_id)
# Sync to Marzban
user = await db.get_user(user_id)
marz_user = await marzban.get_user(user['marzban_username'])
# Marzban treats 0 or negative as no limit or infinity?
# Actually in our server.py we used:
days_left = (new_date - datetime.now()).days + 1 if days > 0 else 0
marz_days = days_left if days < 10000 else 0
await marzban.modify_user(
user['marzban_username'],
(user['data_limit'] / (1024**3)),
expire_timestamp=marz_days if days > 0 else 1 # 1 sec if expired
)
await message.answer(f"✅ Срок установлен: {days} дней")
await show_user_panel(message, user_id)
await state.clear()
except Exception as e:
await message.answer(f"❌ Ошибка: {e}")
# Set Plan (All plans visible to admin)
@router.callback_query(F.data.startswith("adm_usr_plan_"))
async def adm_usr_plan_list(callback: CallbackQuery):
user_id = int(callback.data.split("_")[3])
kb_btns = []
# Show ALL plans from CONFIG to admin
for pid, p in PLANS.items():
kb_btns.append([InlineKeyboardButton(text=f"{p['name']} ({p['days']}d / {p['data_limit'] or ''}GB)",
callback_data=f"adm_setplan_{user_id}_{pid}")])
kb_btns.append([InlineKeyboardButton(text="◀️ Отмена", callback_data=f"adm_sel_{user_id}")])
await callback.message.edit_text(
f"Выберите тарифный план для применения пользователю {user_id}:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_btns)
)
@router.callback_query(F.data.startswith("adm_setplan_"))
async def adm_usr_plan_process(callback: CallbackQuery):
parts = callback.data.split("_")
user_id = int(parts[2])
plan_id = parts[3]
plan = PLANS.get(plan_id)
if not plan:
await callback.answer("Ошибка: Тариф не найден", show_alert=True)
return
user = await db.get_user(user_id)
if not user: return
total_days = plan['days']
data_limit_gb = plan['data_limit']
limit_bytes = int(data_limit_gb * (1024**3)) if data_limit_gb > 0 else 999999 * (1024**3)
# Update DB
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id)
new_date = datetime.now() + timedelta(days=total_days)
await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, user_id)
# Sync to Marzban
marz_days = total_days if total_days > 0 else 0
marz_limit = data_limit_gb if data_limit_gb > 0 else 0
try:
await marzban.modify_user(user['marzban_username'],
marz_limit,
marz_days if marz_days > 0 else 1)
await callback.answer(f"✅ План {plan['name']} успешно применен!", show_alert=True)
except Exception as e:
await callback.answer(f"⚠️ БД обновлена, но Marzban вернул ошибку: {e}", show_alert=True)
await show_user_panel(callback, user_id)
# Reset Traffic
@router.callback_query(F.data.startswith("adm_usr_reset_"))
async def adm_usr_reset(callback: CallbackQuery):
@@ -673,14 +783,18 @@ async def adm_usr_limit_process(message: Message, state: FSMContext):
marz_user = await marzban.get_user(user['marzban_username'])
expire_ts = marz_user.get('expire')
current_status = marz_user.get('status', 'active')
await marzban.modify_user(user['marzban_username'], limit_gb, status=current_status, expire_timestamp=expire_ts)
limit_bytes = int(limit_gb * 1024 * 1024 * 1024)
# 0 in our logic means unlimited
marz_limit = limit_gb if limit_gb > 0 else 0
await marzban.modify_user(user['marzban_username'], marz_limit, status=current_status, expire_timestamp=expire_ts)
# Store in DB
limit_bytes = int(limit_gb * (1024**3)) if limit_gb > 0 else 999999 * (1024**3)
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id)
await message.answer(f"✅ Лимит изменен на {limit_gb} GB")
await message.answer(f"✅ Лимит изменен на {limit_gb if limit_gb > 0 else ''} GB")
await show_user_panel(message, user_id)
await state.clear()
except ValueError:

View File

@@ -149,9 +149,10 @@ async def process_payment(callback: CallbackQuery, state: FSMContext):
await state.clear()
else:
# Создаем инвойс для Telegram Stars
limit_str = f"{plan['data_limit']} ГБ" if plan['data_limit'] > 0 else "Безлимит"
await callback.message.answer_invoice(
title=f"Подписка VPN - {plan['name']}",
description=f"Трафик: {plan['data_limit']} ГБ на {plan['days']} дней",
description=f"Трафик: {limit_str} на {plan['days']} дней",
payload=f"{plan_id}:{data.get('promo_code', '')}",
currency="XTR", # Telegram Stars
prices=[LabeledPrice(label=plan['name'], amount=final_price)],
@@ -206,10 +207,11 @@ async def successful_payment(message: Message):
bonus_days
)
limit_str = f"{plan['data_limit']} ГБ" if plan['data_limit'] > 0 else "Безлимит"
await message.answer(
f"✅ Оплата успешна!\n\n"
f"Ваша подписка активирована на {date_days} дней.\n"
f"Трафик: {plan['data_limit']} ГБ\n"
f"Трафик: {limit_str}\n"
f"{sticky_text}\n"
f"Получите конфигурацию через меню: 📊 Моя подписка",
reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"]),

View File

@@ -23,7 +23,9 @@ def main_keyboard(is_admin: bool = False, has_active_sub: bool = False) -> Inlin
def plans_keyboard() -> InlineKeyboardMarkup:
buttons = []
for plan_id, plan in PLANS.items():
# Only show first 3 plans to users
visible_plans = list(PLANS.items())[:3]
for plan_id, plan in visible_plans:
buttons.append([
InlineKeyboardButton(
text=f"{plan['name']} - {plan['price']}",

View File

@@ -71,8 +71,10 @@ async def main():
web_app.state.bot = bot
config = uvicorn.Config(web_app, host="0.0.0.0", port=CONFIG["WEB_APP_PORT"], log_level="info")
server = uvicorn.Server(config)
except ImportError:
logger.error("Could not import server or uvicorn")
except Exception as e:
logger.error(f"FAILED to start Web App: {e}")
import traceback
traceback.print_exc()
server = None
try:

361
server.py
View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from datetime import datetime
import aiohttp
from pydantic import BaseModel
from datetime import datetime, timedelta
import logging
import json
@@ -32,7 +34,9 @@ async def startup():
@app.get("/api/plans")
async def get_plans():
plans_list = []
for pid, p in PLANS.items():
# Only return first 3 plans for the shop
visible_plans = list(PLANS.items())[:3]
for pid, p in visible_plans:
plans_list.append({
"id": pid,
**p
@@ -40,7 +44,7 @@ async def get_plans():
return plans_list
from aiogram.types import LabeledPrice
from pydantic import BaseModel
class BuyPlanRequest(BaseModel):
user_id: int
@@ -87,9 +91,10 @@ async def create_invoice(req: BuyPlanRequest, request: Request):
if final_price < 1: final_price = 1
try:
limit_desc = f"{plan['data_limit']}GB" if plan['data_limit'] > 0 else "Unlimited"
invoice_link = await bot.create_invoice_link(
title=f"Sub: {plan['name']}",
description=f"{plan['data_limit']}GB / {plan['days']} days",
description=f"{limit_desc} / {plan['days']} days",
payload=f"{req.plan_id}:{req.promo_code or ''}",
provider_token="", # Empty for Stars
currency="XTR",
@@ -146,6 +151,22 @@ async def get_user_stats(user_id: int):
except Exception as e:
logger.error(f"Marzban fetch error: {e}")
# Registration Date
created_at = user['created_at']
reg_date = "Unknown"
if created_at:
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at)
except:
pass
if isinstance(created_at, datetime):
reg_date = created_at.strftime("%d.%m.%Y")
# Referral and Payment Stats
ref_count = await db.get_referrals_count(user_id)
payments_info = await db.get_user_payments_info(user_id)
return {
"status": status,
"days_left": days_left,
@@ -155,9 +176,337 @@ async def get_user_stats(user_id: int):
"plan": "Premium",
"subscription_url": sub_url,
"username": user['username'],
"marzban_username": user['marzban_username']
"marzban_username": user['marzban_username'],
"photo_url": f"/api/user-photo/{user_id}",
"reg_date": reg_date,
"referrals_count": ref_count,
"total_payments": payments_info["total_count"],
"total_spent": payments_info["total_amount"],
"is_admin": is_admin(user_id)
}
@app.get("/api/user-photo/{user_id}")
async def get_user_photo(user_id: int):
# This is a proxy to get TG photo without leaking BOT_TOKEN
async with aiohttp.ClientSession() as session:
# 1. Get user profile photos
get_photos_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getUserProfilePhotos?user_id={user_id}&limit=1"
async with session.get(get_photos_url) as resp:
if resp.status != 200:
return Response(status_code=404)
data = await resp.json()
if not data.get('ok') or not data['result']['photos']:
return Response(status_code=404)
file_id = data['result']['photos'][0][0]['file_id']
# 2. Get file path
get_file_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getFile?file_id={file_id}"
async with session.get(get_file_url) as resp:
if resp.status != 200:
return Response(status_code=404)
file_data = await resp.json()
if not file_data.get('ok'):
return Response(status_code=404)
file_path = file_data['result']['file_path']
# 3. Download and stream the file
file_url = f"https://api.telegram.org/file/bot{CONFIG['BOT_TOKEN']}/{file_path}"
async with session.get(file_url) as resp:
if resp.status != 200:
return Response(status_code=404)
content = await resp.read()
return Response(content=content, media_type="image/jpeg")
# ... existing code ...
class SupportRequest(BaseModel):
user_id: int
message: str
username: str = "Unknown"
@app.post("/api/support")
async def send_support_message(req: SupportRequest, request: Request):
bot = getattr(request.app.state, "bot", None)
if not bot:
return JSONResponse(status_code=500, content={"error": "Bot not init"})
# Send to admins
for admin_id in CONFIG["ADMIN_IDS"]:
try:
text = f"📩 **Support Request**\nFrom: @{req.username} (ID: {req.user_id})\n\n{req.message}"
await bot.send_message(chat_id=admin_id, text=text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Failed to send support msg to {admin_id}: {e}")
return {"status": "sent"}
# --- ADMIN API ---
def is_admin(user_id: int):
return user_id in CONFIG["ADMIN_IDS"]
@app.get("/api/admin/stats")
async def get_admin_stats(user_id: int):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
db_stats = await db.get_stats()
try:
sys_stats = await marzban.get_system_stats()
marz_user_stats = await marzban.get_users_stats()
except:
sys_stats = {}
marz_user_stats = {}
return {
"bot": db_stats,
"server": {
"cpu": sys_stats.get('cpu_usage', 'N/A'),
"ram_used": round(sys_stats.get('mem_used', 0) / (1024**3), 2),
"ram_total": round(sys_stats.get('mem_total', 0) / (1024**3), 2),
"active_users": marz_user_stats.get('active_users', 0),
"total_traffic_gb": round(marz_user_stats.get('total_usage', 0) / (1024**3), 2)
}
}
@app.get("/api/admin/users")
async def admin_list_users(user_id: int, query: str = None):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
if query:
users = await db.search_users(query)
else:
users = await db.get_all_users()
# Convert to dict and add photo_url
results = []
for u in users:
d = dict(u)
d['photo_url'] = f"/api/user-photo/{u['user_id']}"
results.append(d)
# Sort alphabetically by username (case-insensitive), or ID if username is missing
results.sort(key=lambda x: (x.get('username') or str(x['user_id'])).lower())
# Return first 50 results
return results[:50]
@app.get("/api/admin/user/{target_id}")
async def admin_get_user(target_id: int, user_id: int):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
user = await db.get_user(target_id)
if not user:
return JSONResponse(status_code=404, content={"error": "User not found"})
marz_info = {}
try:
marz_info = await marzban.get_user(user['marzban_username'])
except:
pass
return {
"user": dict(user),
"marzban": marz_info
}
@app.post("/api/admin/user/{target_id}/action")
async def admin_user_action(target_id: int, req: Request):
data = await req.json()
admin_id = data.get("user_id")
if not is_admin(admin_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
action = data.get("action")
user = await db.get_user(target_id)
if not user:
return JSONResponse(status_code=404, content={"error": "User not found"})
try:
if action == "reset_traffic":
await marzban.reset_user_traffic(user['marzban_username'])
elif action == "toggle_status":
marz_user = await marzban.get_user(user['marzban_username'])
new_status = "disabled" if marz_user.get('status') == 'active' else 'active'
limit_gb = (marz_user.get('data_limit') or 0) / (1024**3)
await marzban.modify_user(user['marzban_username'],
limit_gb=limit_gb,
status=new_status,
expire_timestamp=marz_user.get('expire'))
elif action == "add_days":
days = int(data.get("days", 0))
current_limit = user['data_limit'] or 0
await db.update_subscription(target_id, days, current_limit)
# Sync to Marzban
u = await db.get_user(target_id)
sub_until = u['subscription_until']
if isinstance(sub_until, str): sub_until = datetime.fromisoformat(sub_until)
days_left = (sub_until - datetime.now()).days + 1 if sub_until else 0
await marzban.modify_user(u['marzban_username'], (current_limit / (1024**3)), days_left)
elif action == "set_limit":
limit_gb = float(data.get("limit_gb", 0))
marz_user = await marzban.get_user(user['marzban_username'])
# 0 and negative in Marzban is unlimited
marz_limit = limit_gb if limit_gb > 0 else 0
await marzban.modify_user(user['marzban_username'], marz_limit,
status=marz_user.get('status'),
expire_timestamp=marz_user.get('expire'))
# If 0, we store a very large number in DB to represent infinity
limit_bytes = int(limit_gb * (1024**3)) if limit_gb > 0 else 999999 * (1024**3)
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id)
elif action == "set_expiry":
days = int(data.get("days", 0))
# Set fixed expiry from NOW
if days > 10000:
new_date = datetime(2099, 12, 31)
else:
new_date = datetime.now() + timedelta(days=days)
await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id)
# Sync to Marzban
u = await db.get_user(target_id)
marz_user = await marzban.get_user(u['marzban_username'])
# In Marzban, we pass days left
days_left = (new_date - datetime.now()).days + 1 if days > 0 else 0
# If days > 10000 (forever), Marzban should be None
marz_days = days_left if days < 10000 else 0
await marzban.modify_user(u['marzban_username'],
(u['data_limit'] / (1024**3)),
marz_days if days > 0 else 1) # 1 sec if expire
elif action == "set_plan":
plan_id = data.get("plan_id")
plan = PLANS.get(plan_id)
if not plan:
return JSONResponse(status_code=404, content={"error": "Plan not found"})
# Use grant_subscription logic
total_days = plan['days']
data_limit_gb = plan['data_limit']
limit_bytes = int(data_limit_gb * (1024**3)) if data_limit_gb > 0 else 999999 * (1024**3)
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id)
# Update expiry relative to now
new_date = datetime.now() + timedelta(days=total_days)
await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id)
# Sync to Marzban
marz_days = total_days if total_days > 0 else 0
marz_limit = data_limit_gb if data_limit_gb > 0 else 0
await marzban.modify_user(user['marzban_username'],
marz_limit,
marz_days if marz_days > 0 else 1)
elif action == "delete_sub":
await db.remove_subscription(target_id)
expire_ts = int(datetime.now().timestamp())
marz_user = await marzban.get_user(user['marzban_username'])
limit_gb = (marz_user.get('data_limit') or 0) / (1024**3)
await marzban.modify_user(user['marzban_username'],
limit_gb,
expire_timestamp=expire_ts)
else:
return JSONResponse(status_code=400, content={"error": "Invalid action"})
return {"status": "ok"}
except Exception as e:
logger.error(f"Admin action error: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/api/admin/promos")
async def admin_list_promos(user_id: int):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
promos = await db.fetch("SELECT * FROM promo_codes")
return [dict(p) for p in promos]
@app.get("/api/admin/plans_full")
async def admin_get_plans_full(user_id: int):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
plans_list = []
for pid, p in PLANS.items():
plans_list.append({"id": pid, **p})
return plans_list
class CreatePromoRequest(BaseModel):
user_id: int
code: str
discount: int
uses: int
days: int
is_unlimited: bool = False
bonus_days: int = 0
is_sticky: bool = False
@app.post("/api/admin/promos/create")
async def admin_create_promo(req: CreatePromoRequest):
if not is_admin(req.user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
expires_at = None
if req.days > 0:
expires_at = datetime.now() + timedelta(days=req.days)
try:
await db.create_promo_code(
req.code.upper().strip(),
req.discount,
req.uses,
req.user_id,
expires_at,
req.is_unlimited,
req.bonus_days,
req.is_sticky
)
return {"status": "ok"}
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.delete("/api/admin/promo/{code}")
async def admin_delete_promo(code: str, user_id: int):
if not is_admin(user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
await db.delete_promo_code(code)
return {"status": "ok"}
class BroadcastRequest(BaseModel):
user_id: int
message: str
@app.post("/api/admin/broadcast")
async def admin_broadcast(req: BroadcastRequest, request: Request):
if not is_admin(req.user_id):
return JSONResponse(status_code=403, content={"error": "Forbidden"})
bot = getattr(request.app.state, "bot", None)
if not bot:
return JSONResponse(status_code=500, content={"error": "Bot not init"})
users = await db.get_users_for_broadcast()
count = 0
for u in users:
try:
await bot.send_message(chat_id=u['user_id'], text=req.message)
count += 1
except:
pass
return {"sent": count}
# Serve Static Files (must be last)
app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static")

View File

@@ -20,3 +20,4 @@ class AdminUserStates(StatesGroup):
waiting_for_days = State()
waiting_for_message = State()
waiting_for_limit = State()
waiting_for_fixed_days = State()

View File

@@ -0,0 +1,51 @@
.dark-high-contrast {
--md-sys-color-primary: rgb(235 240 255);
--md-sys-color-surface-tint: rgb(170 199 255);
--md-sys-color-on-primary: rgb(0 0 0);
--md-sys-color-primary-container: rgb(166 195 252);
--md-sys-color-on-primary-container: rgb(0 11 32);
--md-sys-color-secondary: rgb(235 240 255);
--md-sys-color-on-secondary: rgb(0 0 0);
--md-sys-color-secondary-container: rgb(186 195 216);
--md-sys-color-on-secondary-container: rgb(3 11 26);
--md-sys-color-tertiary: rgb(255 233 255);
--md-sys-color-on-tertiary: rgb(0 0 0);
--md-sys-color-tertiary-container: rgb(216 184 220);
--md-sys-color-on-tertiary-container: rgb(22 4 29);
--md-sys-color-error: rgb(255 236 233);
--md-sys-color-on-error: rgb(0 0 0);
--md-sys-color-error-container: rgb(255 174 164);
--md-sys-color-on-error-container: rgb(34 0 1);
--md-sys-color-background: rgb(17 19 24);
--md-sys-color-on-background: rgb(226 226 233);
--md-sys-color-surface: rgb(17 19 24);
--md-sys-color-on-surface: rgb(255 255 255);
--md-sys-color-surface-variant: rgb(68 71 78);
--md-sys-color-on-surface-variant: rgb(255 255 255);
--md-sys-color-outline: rgb(238 239 249);
--md-sys-color-outline-variant: rgb(192 194 204);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(226 226 233);
--md-sys-color-inverse-on-surface: rgb(0 0 0);
--md-sys-color-inverse-primary: rgb(41 72 120);
--md-sys-color-primary-fixed: rgb(214 227 255);
--md-sys-color-on-primary-fixed: rgb(0 0 0);
--md-sys-color-primary-fixed-dim: rgb(170 199 255);
--md-sys-color-on-primary-fixed-variant: rgb(0 17 43);
--md-sys-color-secondary-fixed: rgb(218 226 249);
--md-sys-color-on-secondary-fixed: rgb(0 0 0);
--md-sys-color-secondary-fixed-dim: rgb(190 198 220);
--md-sys-color-on-secondary-fixed-variant: rgb(8 17 33);
--md-sys-color-tertiary-fixed: rgb(250 216 253);
--md-sys-color-on-tertiary-fixed: rgb(0 0 0);
--md-sys-color-tertiary-fixed-dim: rgb(221 188 224);
--md-sys-color-on-tertiary-fixed-variant: rgb(29 8 35);
--md-sys-color-surface-dim: rgb(17 19 24);
--md-sys-color-surface-bright: rgb(78 80 86);
--md-sys-color-surface-container-lowest: rgb(0 0 0);
--md-sys-color-surface-container-low: rgb(29 32 36);
--md-sys-color-surface-container: rgb(46 48 54);
--md-sys-color-surface-container-high: rgb(57 59 65);
--md-sys-color-surface-container-highest: rgb(69 71 76);
}

View File

@@ -0,0 +1,51 @@
.dark-medium-contrast {
--md-sys-color-primary: rgb(205 221 255);
--md-sys-color-surface-tint: rgb(170 199 255);
--md-sys-color-on-primary: rgb(0 37 81);
--md-sys-color-primary-container: rgb(116 145 199);
--md-sys-color-on-primary-container: rgb(0 0 0);
--md-sys-color-secondary: rgb(212 220 242);
--md-sys-color-on-secondary: rgb(29 38 54);
--md-sys-color-secondary-container: rgb(136 145 165);
--md-sys-color-on-secondary-container: rgb(0 0 0);
--md-sys-color-tertiary: rgb(243 210 247);
--md-sys-color-on-tertiary: rgb(51 29 57);
--md-sys-color-tertiary-container: rgb(164 135 169);
--md-sys-color-on-tertiary-container: rgb(0 0 0);
--md-sys-color-error: rgb(255 210 204);
--md-sys-color-on-error: rgb(84 0 3);
--md-sys-color-error-container: rgb(255 84 73);
--md-sys-color-on-error-container: rgb(0 0 0);
--md-sys-color-background: rgb(17 19 24);
--md-sys-color-on-background: rgb(226 226 233);
--md-sys-color-surface: rgb(17 19 24);
--md-sys-color-on-surface: rgb(255 255 255);
--md-sys-color-surface-variant: rgb(68 71 78);
--md-sys-color-on-surface-variant: rgb(218 220 230);
--md-sys-color-outline: rgb(175 178 187);
--md-sys-color-outline-variant: rgb(142 144 153);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(226 226 233);
--md-sys-color-inverse-on-surface: rgb(40 42 47);
--md-sys-color-inverse-primary: rgb(41 72 120);
--md-sys-color-primary-fixed: rgb(214 227 255);
--md-sys-color-on-primary-fixed: rgb(0 17 43);
--md-sys-color-primary-fixed-dim: rgb(170 199 255);
--md-sys-color-on-primary-fixed-variant: rgb(19 54 101);
--md-sys-color-secondary-fixed: rgb(218 226 249);
--md-sys-color-on-secondary-fixed: rgb(8 17 33);
--md-sys-color-secondary-fixed-dim: rgb(190 198 220);
--md-sys-color-on-secondary-fixed-variant: rgb(46 54 71);
--md-sys-color-tertiary-fixed: rgb(250 216 253);
--md-sys-color-on-tertiary-fixed: rgb(29 8 35);
--md-sys-color-tertiary-fixed-dim: rgb(221 188 224);
--md-sys-color-on-tertiary-fixed-variant: rgb(69 46 74);
--md-sys-color-surface-dim: rgb(17 19 24);
--md-sys-color-surface-bright: rgb(67 68 74);
--md-sys-color-surface-container-lowest: rgb(6 7 12);
--md-sys-color-surface-container-low: rgb(27 30 34);
--md-sys-color-surface-container: rgb(38 40 45);
--md-sys-color-surface-container-high: rgb(49 50 56);
--md-sys-color-surface-container-highest: rgb(60 62 67);
}

View File

@@ -0,0 +1,53 @@
/* Dark Theme Tokens */
body.dark,
.dark {
--md-sys-color-primary: rgb(170 199 255);
--md-sys-color-surface-tint: rgb(170 199 255);
--md-sys-color-on-primary: rgb(10 48 95);
--md-sys-color-primary-container: rgb(40 71 119);
--md-sys-color-on-primary-container: rgb(214 227 255);
--md-sys-color-secondary: rgb(190 198 220);
--md-sys-color-on-secondary: rgb(40 49 65);
--md-sys-color-secondary-container: rgb(62 71 89);
--md-sys-color-on-secondary-container: rgb(218 226 249);
--md-sys-color-tertiary: rgb(221 188 224);
--md-sys-color-on-tertiary: rgb(63 40 68);
--md-sys-color-tertiary-container: rgb(87 62 92);
--md-sys-color-on-tertiary-container: rgb(250 216 253);
--md-sys-color-error: rgb(255 180 171);
--md-sys-color-on-error: rgb(105 0 5);
--md-sys-color-error-container: rgb(147 0 10);
--md-sys-color-on-error-container: rgb(255 218 214);
--md-sys-color-background: rgb(17 19 24);
--md-sys-color-on-background: rgb(226 226 233);
--md-sys-color-surface: rgb(17 19 24);
--md-sys-color-on-surface: rgb(226 226 233);
--md-sys-color-surface-variant: rgb(68 71 78);
--md-sys-color-on-surface-variant: rgb(196 198 208);
--md-sys-color-outline: rgb(142 144 153);
--md-sys-color-outline-variant: rgb(68 71 78);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(226 226 233);
--md-sys-color-inverse-on-surface: rgb(46 48 54);
--md-sys-color-inverse-primary: rgb(65 95 145);
--md-sys-color-primary-fixed: rgb(214 227 255);
--md-sys-color-on-primary-fixed: rgb(0 27 62);
--md-sys-color-primary-fixed-dim: rgb(170 199 255);
--md-sys-color-on-primary-fixed-variant: rgb(40 71 119);
--md-sys-color-secondary-fixed: rgb(218 226 249);
--md-sys-color-on-secondary-fixed: rgb(19 28 43);
--md-sys-color-secondary-fixed-dim: rgb(190 198 220);
--md-sys-color-on-secondary-fixed-variant: rgb(62 71 89);
--md-sys-color-tertiary-fixed: rgb(250 216 253);
--md-sys-color-on-tertiary-fixed: rgb(40 19 46);
--md-sys-color-tertiary-fixed-dim: rgb(221 188 224);
--md-sys-color-on-tertiary-fixed-variant: rgb(87 62 92);
--md-sys-color-surface-dim: rgb(17 19 24);
--md-sys-color-surface-bright: rgb(55 57 62);
--md-sys-color-surface-container-lowest: rgb(12 14 19);
--md-sys-color-surface-container-low: rgb(25 28 32);
--md-sys-color-surface-container: rgb(29 32 36);
--md-sys-color-surface-container-high: rgb(40 42 47);
--md-sys-color-surface-container-highest: rgb(51 53 58);
}

View File

@@ -0,0 +1,51 @@
.light-high-contrast {
--md-sys-color-primary: rgb(3 43 91);
--md-sys-color-surface-tint: rgb(65 95 145);
--md-sys-color-on-primary: rgb(255 255 255);
--md-sys-color-primary-container: rgb(42 73 122);
--md-sys-color-on-primary-container: rgb(255 255 255);
--md-sys-color-secondary: rgb(35 44 61);
--md-sys-color-on-secondary: rgb(255 255 255);
--md-sys-color-secondary-container: rgb(65 73 91);
--md-sys-color-on-secondary-container: rgb(255 255 255);
--md-sys-color-tertiary: rgb(58 36 64);
--md-sys-color-on-tertiary: rgb(255 255 255);
--md-sys-color-tertiary-container: rgb(89 64 94);
--md-sys-color-on-tertiary-container: rgb(255 255 255);
--md-sys-color-error: rgb(96 0 4);
--md-sys-color-on-error: rgb(255 255 255);
--md-sys-color-error-container: rgb(152 0 10);
--md-sys-color-on-error-container: rgb(255 255 255);
--md-sys-color-background: rgb(249 249 255);
--md-sys-color-on-background: rgb(25 28 32);
--md-sys-color-surface: rgb(249 249 255);
--md-sys-color-on-surface: rgb(0 0 0);
--md-sys-color-surface-variant: rgb(224 226 236);
--md-sys-color-on-surface-variant: rgb(0 0 0);
--md-sys-color-outline: rgb(41 44 51);
--md-sys-color-outline-variant: rgb(70 73 81);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(46 48 54);
--md-sys-color-inverse-on-surface: rgb(255 255 255);
--md-sys-color-inverse-primary: rgb(170 199 255);
--md-sys-color-primary-fixed: rgb(42 73 122);
--md-sys-color-on-primary-fixed: rgb(255 255 255);
--md-sys-color-primary-fixed-dim: rgb(14 50 98);
--md-sys-color-on-primary-fixed-variant: rgb(255 255 255);
--md-sys-color-secondary-fixed: rgb(65 73 91);
--md-sys-color-on-secondary-fixed: rgb(255 255 255);
--md-sys-color-secondary-fixed-dim: rgb(42 51 68);
--md-sys-color-on-secondary-fixed-variant: rgb(255 255 255);
--md-sys-color-tertiary-fixed: rgb(89 64 94);
--md-sys-color-on-tertiary-fixed: rgb(255 255 255);
--md-sys-color-tertiary-fixed-dim: rgb(65 42 71);
--md-sys-color-on-tertiary-fixed-variant: rgb(255 255 255);
--md-sys-color-surface-dim: rgb(184 184 191);
--md-sys-color-surface-bright: rgb(249 249 255);
--md-sys-color-surface-container-lowest: rgb(255 255 255);
--md-sys-color-surface-container-low: rgb(240 240 247);
--md-sys-color-surface-container: rgb(226 226 233);
--md-sys-color-surface-container-high: rgb(211 212 219);
--md-sys-color-surface-container-highest: rgb(197 198 205);
}

View File

@@ -0,0 +1,51 @@
.light-medium-contrast {
--md-sys-color-primary: rgb(19 54 101);
--md-sys-color-surface-tint: rgb(65 95 145);
--md-sys-color-on-primary: rgb(255 255 255);
--md-sys-color-primary-container: rgb(80 109 160);
--md-sys-color-on-primary-container: rgb(255 255 255);
--md-sys-color-secondary: rgb(46 54 71);
--md-sys-color-on-secondary: rgb(255 255 255);
--md-sys-color-secondary-container: rgb(100 109 128);
--md-sys-color-on-secondary-container: rgb(255 255 255);
--md-sys-color-tertiary: rgb(69 46 74);
--md-sys-color-on-tertiary: rgb(255 255 255);
--md-sys-color-tertiary-container: rgb(127 100 132);
--md-sys-color-on-tertiary-container: rgb(255 255 255);
--md-sys-color-error: rgb(116 0 6);
--md-sys-color-on-error: rgb(255 255 255);
--md-sys-color-error-container: rgb(207 44 39);
--md-sys-color-on-error-container: rgb(255 255 255);
--md-sys-color-background: rgb(249 249 255);
--md-sys-color-on-background: rgb(25 28 32);
--md-sys-color-surface: rgb(249 249 255);
--md-sys-color-on-surface: rgb(15 17 22);
--md-sys-color-surface-variant: rgb(224 226 236);
--md-sys-color-on-surface-variant: rgb(51 54 62);
--md-sys-color-outline: rgb(79 82 90);
--md-sys-color-outline-variant: rgb(106 109 117);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(46 48 54);
--md-sys-color-inverse-on-surface: rgb(240 240 247);
--md-sys-color-inverse-primary: rgb(170 199 255);
--md-sys-color-primary-fixed: rgb(80 109 160);
--md-sys-color-on-primary-fixed: rgb(255 255 255);
--md-sys-color-primary-fixed-dim: rgb(55 85 134);
--md-sys-color-on-primary-fixed-variant: rgb(255 255 255);
--md-sys-color-secondary-fixed: rgb(100 109 128);
--md-sys-color-on-secondary-fixed: rgb(255 255 255);
--md-sys-color-secondary-fixed-dim: rgb(76 85 103);
--md-sys-color-on-secondary-fixed-variant: rgb(255 255 255);
--md-sys-color-tertiary-fixed: rgb(127 100 132);
--md-sys-color-on-tertiary-fixed: rgb(255 255 255);
--md-sys-color-tertiary-fixed-dim: rgb(101 76 107);
--md-sys-color-on-tertiary-fixed-variant: rgb(255 255 255);
--md-sys-color-surface-dim: rgb(197 198 205);
--md-sys-color-surface-bright: rgb(249 249 255);
--md-sys-color-surface-container-lowest: rgb(255 255 255);
--md-sys-color-surface-container-low: rgb(243 243 250);
--md-sys-color-surface-container: rgb(231 232 238);
--md-sys-color-surface-container-high: rgb(220 220 227);
--md-sys-color-surface-container-highest: rgb(209 209 216);
}

View File

@@ -0,0 +1,53 @@
/* Light Theme Tokens */
body.light,
.light {
--md-sys-color-primary: rgb(65 95 145);
--md-sys-color-surface-tint: rgb(65 95 145);
--md-sys-color-on-primary: rgb(255 255 255);
--md-sys-color-primary-container: rgb(214 227 255);
--md-sys-color-on-primary-container: rgb(40 71 119);
--md-sys-color-secondary: rgb(86 95 113);
--md-sys-color-on-secondary: rgb(255 255 255);
--md-sys-color-secondary-container: rgb(218 226 249);
--md-sys-color-on-secondary-container: rgb(62 71 89);
--md-sys-color-tertiary: rgb(112 85 117);
--md-sys-color-on-tertiary: rgb(255 255 255);
--md-sys-color-tertiary-container: rgb(250 216 253);
--md-sys-color-on-tertiary-container: rgb(87 62 92);
--md-sys-color-error: rgb(186 26 26);
--md-sys-color-on-error: rgb(255 255 255);
--md-sys-color-error-container: rgb(255 218 214);
--md-sys-color-on-error-container: rgb(147 0 10);
--md-sys-color-background: rgb(249 249 255);
--md-sys-color-on-background: rgb(25 28 32);
--md-sys-color-surface: rgb(249 249 255);
--md-sys-color-on-surface: rgb(25 28 32);
--md-sys-color-surface-variant: rgb(224 226 236);
--md-sys-color-on-surface-variant: rgb(68 71 78);
--md-sys-color-outline: rgb(116 119 127);
--md-sys-color-outline-variant: rgb(196 198 208);
--md-sys-color-shadow: rgb(0 0 0);
--md-sys-color-scrim: rgb(0 0 0);
--md-sys-color-inverse-surface: rgb(46 48 54);
--md-sys-color-inverse-on-surface: rgb(240 240 247);
--md-sys-color-inverse-primary: rgb(170 199 255);
--md-sys-color-primary-fixed: rgb(214 227 255);
--md-sys-color-on-primary-fixed: rgb(0 27 62);
--md-sys-color-primary-fixed-dim: rgb(170 199 255);
--md-sys-color-on-primary-fixed-variant: rgb(40 71 119);
--md-sys-color-secondary-fixed: rgb(218 226 249);
--md-sys-color-on-secondary-fixed: rgb(19 28 43);
--md-sys-color-secondary-fixed-dim: rgb(190 198 220);
--md-sys-color-on-secondary-fixed-variant: rgb(62 71 89);
--md-sys-color-tertiary-fixed: rgb(250 216 253);
--md-sys-color-on-tertiary-fixed: rgb(40 19 46);
--md-sys-color-tertiary-fixed-dim: rgb(221 188 224);
--md-sys-color-on-tertiary-fixed-variant: rgb(87 62 92);
--md-sys-color-surface-dim: rgb(217 217 224);
--md-sys-color-surface-bright: rgb(249 249 255);
--md-sys-color-surface-container-lowest: rgb(255 255 255);
--md-sys-color-surface-container-low: rgb(243 243 250);
--md-sys-color-surface-container: rgb(237 237 244);
--md-sys-color-surface-container-high: rgb(231 232 238);
--md-sys-color-surface-container-highest: rgb(226 226 233);
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,232 +4,416 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Comet VPN</title>
<title>Stellarisei VPN</title>
<!-- Telegram Web App SDK -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=2">
<!-- Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<!-- Styles -->
<link rel="stylesheet" href="css/dark.css">
<link rel="stylesheet" href="css/light.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<body class="dark">
<div id="stars-container"></div>
<div class="glow-overlay"></div>
<div id="toast-container"></div>
<div class="app-layout">
<!-- Sidebar (Desktop) -->
<aside class="sidebar glass">
<div class="logo">
<i data-lucide="rocket" class="logo-icon"></i>
<span>CometBot</span>
<div class="app-shell">
<!-- Desktop Rail (Full Features) -->
<nav class="nav-rail">
<div class="logo-area">
<i data-lucide="rocket" style="width:32px; height:32px;"></i>
</div>
<nav class="nav-menu">
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-dashboard"></i> <span>Dashboard</span>
<div class="nav-destinations">
<button class="rail-item active nav-item" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-grid"></i>
<span data-t="nav_home">Home</span>
</button>
<button class="nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i> <span>Shop</span>
<button class="rail-item nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i>
<span data-t="nav_shop">Shop</span>
</button>
<button class="nav-item" data-page="subscription" onclick="router('subscription')">
<i data-lucide="key"></i> <span>Config</span>
<button class="rail-item nav-item" data-page="subscription" onclick="router('subscription')">
<i data-lucide="qr-code"></i>
<span data-t="nav_config">Config</span>
</button>
<button class="nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i> <span>Profile</span>
<button class="rail-item nav-item" data-page="promo" onclick="openPromoModal()">
<i data-lucide="ticket"></i>
<span data-t="nav_promo">Promo</span>
</button>
<button class="rail-item nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i>
<span data-t="nav_profile">Profile</span>
</button>
<button class="rail-item nav-item hidden" id="nav-admin" data-page="admin" onclick="router('admin')">
<i data-lucide="shield-check"></i>
<span data-t="nav_admin">Admin</span>
</button>
</div>
<button class="rail-fab" onclick="openSupport()">
<i data-lucide="message-square"></i>
</button>
</nav>
</aside>
<!-- Main Content -->
<main class="content-area">
<header class="mobile-header glass">
<div class="logo-mini">
<i data-lucide="rocket"></i> Comet
<!-- Main Content Pane -->
<main class="main-pane">
<header class="mobile-header">
<div style="display:flex; align-items:center; gap:12px;">
<i data-lucide="rocket" style="color:var(--md-sys-color-primary)"></i>
<span style="font-weight:600; font-size:18px;" id="header-title">Stellarisei</span>
</div>
<div class="user-chip" id="header-user">
<div class="avatar-xs" id="header-avatar">U</div>
<div class="user-chip" id="header-user" onclick="router('profile')">
<div class="avatar-xs" id="header-avatar"
style="width:32px;height:32px;border-radius:50%;background:var(--md-sys-color-primary-container);display:flex;align-items:center;justify-content:center;">
U</div>
</div>
</header>
<div id="app-view" class="view-container">
<!-- View Display -->
<div id="app-view" class="view-content">
<div class="loading-spinner"></div>
</div>
<!-- Bottom Nav (Mobile) -->
<nav class="bottom-nav glass">
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-dashboard"></i>
<div style="height:120px;"></div> <!-- Bottom spacer for mobile -->
</main>
<!-- Mobile Bottom Bar (5 Items) -->
<nav class="bottom-bar">
<button class="bar-item nav-item active" data-page="dashboard" onclick="router('dashboard')">
<div class="bar-pill"><i data-lucide="layout-grid"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_home">Home</span>
</button>
<button class="nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i>
<button class="bar-item nav-item" data-page="shop" onclick="router('shop')">
<div class="bar-pill"><i data-lucide="shopping-bag"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_shop">Shop</span>
</button>
<button class="nav-item center-fab" data-page="subscription" onclick="router('subscription')">
<div class="fab-bg"><i data-lucide="power"></i></div>
<!-- Center Action: Config -->
<button class="bar-item nav-item" data-page="subscription" onclick="router('subscription')">
<div class="bar-pill"><i data-lucide="qr-code"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_config">Config</span>
</button>
<button class="nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i>
<button class="bar-item nav-item" data-page="promo" onclick="openPromoModal()">
<div class="bar-pill"><i data-lucide="ticket"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_promo">Promo</span>
</button>
<button class="nav-item" onclick="openHelp()">
<i data-lucide="help-circle"></i>
<!-- Profile (hidden for admins) -->
<button class="bar-item nav-item" id="mobile-profile-btn" data-page="profile" onclick="router('profile')">
<div class="bar-pill"><i data-lucide="user"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_profile">Profile</span>
</button>
<!-- Admin (hidden by default, shown for admins) -->
<button class="bar-item nav-item hidden" id="mobile-admin-btn" data-page="admin" onclick="router('admin')">
<div class="bar-pill"><i data-lucide="shield-check"></i></div>
<span style="font-size:10px; margin-top:4px;" data-t="nav_admin">Admin</span>
</button>
</nav>
</main>
</div>
<!-- VIEW TEMPLATES -->
<!-- Modals -->
<div id="modal-overlay" class="modal-overlay hidden" onclick="closeModal(event)">
<div class="modal-dialog">
<div class="modal-header"
style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2 id="modal-title" style="font:var(--type-headline-small);">Title</h2>
<button class="close-btn" onclick="closeModal()"
style="background:none; border:none; color:var(--md-sys-color-on-surface-variant); cursor:pointer;"><i
data-lucide="x"></i></button>
</div>
<div id="modal-body"></div>
</div>
</div>
<!-- TEMPLATES -->
<!-- Dashboard -->
<template id="view-dashboard">
<div class="view-header">
<h1>Overview</h1>
<p class="subtitle">Welcome back, <span id="user-name">User</span></p>
</div>
<header class="page-header-large">
<div class="subtitle" data-t="dash_welcome">Welcome back</div>
<h1><span id="user-name">User</span></h1>
</header>
<div class="status-card glass">
<div class="status-ring">
<div class="status-overview">
<div class="ring-card">
<div class="ring-container">
<svg viewBox="0 0 36 36" class="circular-chart">
<path class="circle-bg"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle" id="data-ring" stroke-dasharray="0, 100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="ring-content">
<span class="value" id="dash-data-left">0</span>
<span class="unit">GB Used</span>
<div
style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;">
<i data-lucide="database" style="color:var(--md-sys-color-primary)"></i>
</div>
</div>
<div class="status-details">
<div class="detail-item">
<span class="label">Status</span>
<span class="val status-badge" id="dash-status">Checking...</span>
<div>
<div style="font:var(--type-title-large); color:var(--md-sys-color-on-surface);"><span
id="dash-data-left">0</span> GB</div>
<div style="font:var(--type-body-medium); color:var(--md-sys-color-outline);" data-t="dash_used">
Used Traffic</div>
</div>
<div class="detail-item">
<span class="label">Plan Details</span>
<span class="val" id="dash-limit">0 GB Total</span>
</div>
<div class="detail-item">
<span class="label">Expires</span>
<span class="val" id="dash-expire">-</span>
<div class="ring-card">
<div class="ring-container">
<svg viewBox="0 0 36 36" class="circular-chart">
<path class="circle-bg"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle" id="exp-ring" stroke-dasharray="0, 100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div
style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;">
<i data-lucide="clock" style="color:var(--md-sys-color-primary)"></i>
</div>
</div>
<div>
<div style="font:var(--type-title-large); color:var(--md-sys-color-on-surface);"><span
id="dash-days-left">0</span> Days</div>
<div style="font:var(--type-body-medium); color:var(--md-sys-color-outline);" data-t="dash_expiry">
Until Expiry</div>
</div>
</div>
</div>
<div class="quick-actions">
<button class="action-card glass" onclick="router('shop')">
<div class="icon-circle pop"><i data-lucide="zap"></i></div>
<span>Extend Plan</span>
<div class="m3-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<span class="subtitle" data-t="dash_status">Subscription Status</span>
<span class="status-badge" id="dash-status"
style="background:var(--md-sys-color-secondary-container); color:var(--md-sys-color-on-secondary-container); padding:4px 12px; border-radius:8px; font-weight:600; font-size:12px;">Checking...</span>
</div>
<div class="actions-grid">
<button class="action-btn-large" onclick="router('shop')">
<i data-lucide="zap"></i>
<span data-t="btn_extend">Extend</span>
</button>
<button class="action-card glass" onclick="router('subscription')">
<div class="icon-circle"><i data-lucide="qr-code"></i></div>
<span>Connect</span>
<button class="action-btn-large" onclick="router('subscription')">
<i data-lucide="qr-code"></i>
<span data-t="btn_connect">Connect</span>
</button>
</div>
</div>
</template>
<!-- Shop -->
<template id="view-shop">
<div class="view-header">
<h1>Shop</h1>
<p class="subtitle">Choose your plan</p>
</div>
<div class="plans-list" id="plans-container">
<!-- Injected -->
</div>
<header class="page-header-large">
<h1 data-t="shop_title">Select Plan</h1>
<div class="subtitle" data-t="shop_subtitle">Upgrade your experience</div>
</header>
<div id="plans-container" style="margin-bottom: 60px;"></div>
</template>
<!-- Subscription -->
<template id="view-subscription">
<div class="view-header">
<h1>Connection</h1>
<p class="subtitle">Setup your VPN</p>
<header class="page-header-large">
<h1 data-t="sub_title">Connect</h1>
<div class="subtitle" data-t="sub_subtitle">Setup your device</div>
</header>
<div class="m3-card" style="display:flex; flex-direction:column; align-items:center; text-align:center;">
<div id="qrcode-container" style="background:white; padding:12px; border-radius:16px;"></div>
<div style="margin-top:24px; width:100%;">
<div class="subtitle" style="margin-bottom:8px;" data-t="sub_link_label">Subscription Link</div>
<div style="background:var(--md-sys-color-surface-container); padding:12px; border-radius:12px; display:flex; align-items:center; gap:12px; cursor:pointer;"
onclick="copyConfig()">
<div id="config-link"
style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; font-family:monospace; color:var(--md-sys-color-on-surface);">
...</div>
<i data-lucide="copy" style="color:var(--md-sys-color-primary);"></i>
</div>
<div class="card glass center-content">
<div id="qrcode-container"></div>
<p class="helper-text">Scan availability QR Code</p>
<div class="copy-box" onclick="copyConfig()">
<div class="truncate-text" id="config-link">Loading...</div>
<i data-lucide="copy"></i>
</div>
<div class="sub-actions">
<button class="btn-secondary" onclick="copyConfig()">Copy Link</button>
<button class="btn-primary"
onclick="window.Telegram.WebApp.openLink('https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690')">
Download App
</button>
</div>
</div>
<div class="accordion glass">
<div id="device-apps-container"></div>
<div class="accordion m3-card" style="margin-top:16px;">
<div class="acc-item">
<div class="acc-head" onclick="toggleAcc(this)">
<span>How to connect on iOS?</span>
<span data-t="sub_instructions">Detailed Instructions</span>
<i data-lucide="chevron-down"></i>
</div>
<div class="acc-body">
<ol>
<li>Install <b>V2Box</b> from AppStore.</li>
<li>Copy the link above.</li>
<li>Open V2Box, it will detect the link.</li>
<li>Tap "Import" and Connect.</li>
</ol>
</div>
</div>
<div class="acc-item">
<div class="acc-head" onclick="toggleAcc(this)">
<span>How to connect on Android?</span>
<i data-lucide="chevron-down"></i>
</div>
<div class="acc-body">
<ol>
<li>Install <b>v2rayNG</b> or <b>Hiddify</b>.</li>
<li>Copy the config link.</li>
<li>Open app -> Import from Clipboard.</li>
<li>Connect (V button).</li>
</ol>
<p data-t="sub_instr_1">1. Download app for your device.</p>
<p data-t="sub_instr_2">2. Copy the link above.</p>
<p data-t="sub_instr_3">3. Import from Clipboard.</p>
<p data-t="sub_instr_4">4. Connect.</p>
</div>
</div>
</div>
</template>
<!-- Profile -->
<template id="view-profile">
<div class="view-header">
<h1>Profile</h1>
<div style="text-align:center; margin:40px 0;">
<div class="avatar-xs" id="profile-avatar"
style="width:80px; height:80px; margin:0 auto 16px; border-radius:50%; background:var(--md-sys-color-primary-container); color:var(--md-sys-color-on-primary-container); font-size:32px; display:flex; align-items:center; justify-content:center;">
U</div>
<h2 id="profile-name" style="font:var(--type-headline-medium);">User</h2>
<div id="profile-id" class="subtitle">ID: 000000</div>
</div>
<div class="card glass profile-main">
<div class="big-avatar" id="profile-avatar">U</div>
<h2 id="profile-name">User</h2>
<p id="profile-id" class="id-badge">ID: 000000</p>
<div class="m3-card">
<div class="shop-item">
<span data-t="prof_joined">Joined</span>
<span id="stat-reg-date" style="font-weight:600;">...</span>
</div>
<div class="shop-item">
<span data-t="prof_spent">Total Spent</span>
<span style="font-weight:600;"><span id="stat-spent">0</span> ⭐️</span>
</div>
<div class="shop-item">
<span data-t="prof_purchases">Purchases</span>
<span id="stat-payments" style="font-weight:600;">0</span>
</div>
</div>
<div class="section-title">Promo Code</div>
<div class="card glass promo-card">
<input type="text" id="promo-input" placeholder="ENTER CODE" class="glass-input">
<button class="btn-small" onclick="checkPromo()">Apply</button>
</div>
<div id="promo-result"></div>
<div class="list-menu glass">
<button class="list-item" onclick="window.Telegram.WebApp.openLink('https://t.me/hoshimach1')">
<!-- Restored Support/About Buttons -->
<h3 style="margin-bottom:12px; padding-left:4px;" data-t="prof_app_info">App Info</h3>
<div class="m3-card" style="padding:12px;">
<button class="list-item" onclick="openSupport()" style="width:100%;">
<i data-lucide="message-square"></i>
<span>Support</span>
<span data-t="btn_support">Support</span>
<i data-lucide="chevron-right" class="arrow"></i>
</button>
<button class="list-item" onclick="alert('v1.0.0 Comet')">
<button class="list-item" onclick="openAbout()" style="width:100%;">
<i data-lucide="info"></i>
<span>About</span>
<span data-t="btn_about">About</span>
<i data-lucide="chevron-right" class="arrow"></i>
</button>
</div>
</template>
<script src="js/background.js"></script>
<!-- Admin templates -->
<template id="view-admin">
<header class="page-header-large">
<h1 data-t="adm_title">Admin</h1>
</header>
<div class="admin-tabs" style="display:flex; gap:8px; overflow-x:auto; margin-bottom:24px; padding-bottom:4px;">
<button class="btn-tonal tab-item" onclick="adminTab('stats')" data-t="adm_stats">Stats</button>
<button class="btn-tonal tab-item" onclick="adminTab('users')" data-t="adm_users">Users</button>
<button class="btn-tonal tab-item" onclick="adminTab('promos')" data-t="adm_promos">Promos</button>
<button class="btn-tonal tab-item" onclick="adminTab('broadcast')" data-t="adm_broadcast">Broadcast</button>
</div>
<div id="admin-content"></div>
</template>
<template id="admin-stats">
<div class="m3-card">
<h3 data-t="adm_bot_stats">Bot Stats</h3>
<div
style="margin-top:12px; display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
<span data-t="adm_total_users">Total Users:</span> <b id="adm-total-users">0</b>
</div>
<div
style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
<span data-t="adm_active_subs">Active Subs:</span> <b id="adm-active-subs">0</b>
</div>
<div style="display:flex; justify-content:space-between; padding:8px 0;">
<span data-t="adm_revenue">Revenue:</span> <b id="adm-revenue">0</b>
</div>
</div>
<div class="m3-card">
<h3 data-t="adm_server">Server</h3>
<div
style="margin-top:12px; display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
<span>CPU:</span> <b id="adm-cpu">0%</b>
</div>
<div
style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--md-sys-color-outline-variant);">
<span>RAM:</span> <b id="adm-ram">0/0</b>
</div>
<div style="display:flex; justify-content:space-between; padding:8px 0;">
<span data-t="adm_marzban">Marzban Users:</span> <b id="adm-active-marz">0</b>
</div>
</div>
</template>
<template id="admin-users">
<div class="search-box">
<input type="text" id="admin-user-search" placeholder="Search..." data-tp="ph_search" class="glass-input">
<button class="btn-primary" style="width:auto; padding:0 24px;" onclick="adminSearchUsers()"
data-t="btn_search">Search</button>
</div>
<div id="admin-users-list" class="admin-list"></div>
</template>
<template id="admin-user-detail">
<button class="btn-secondary" onclick="adminTab('users')"
style="margin-bottom:16px; width:auto; padding:0 20px;"><i data-lucide="arrow-left"></i> <span
data-t="btn_back">Back</span></button>
<div class="m3-card" style="margin-bottom:50px;">
<div style="display:flex; align-items:center; gap:16px; margin-bottom:20px;">
<div class="avatar-xs" id="adm-user-avatar"
style="width:48px;height:48px;border-radius:50%;background:var(--md-sys-color-primary-container);display:flex;align-items:center;justify-content:center;">
U</div>
<div>
<div style="font-weight:700; font-size:18px;" id="adm-user-name">User</div>
<div style="font-size:12px;opacity:0.6;" id="adm-user-id">ID: ...</div>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:24px;">
<div class="list-item" style="flex-direction:column; align-items:flex-start; gap:4px;">
<span style="font-size:11px; opacity:0.6" data-t="adm_status">Status</span>
<b id="adm-user-status">...</b>
</div>
<div class="list-item" style="flex-direction:column; align-items:flex-start; gap:4px;">
<span style="font-size:11px; opacity:0.6" data-t="adm_expiry">Expiry</span>
<b id="adm-user-expire">...</b>
</div>
<div class="list-item"
style="grid-column: span 2; flex-direction:column; align-items:flex-start; gap:4px;">
<span style="font-size:11px; opacity:0.6" data-t="adm_traffic">Traffic</span>
<b id="adm-user-traffic">...</b>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<button class="btn-secondary" onclick="adminUserAction('add_days')" data-t="adm_btn_days">+Days</button>
<button class="btn-secondary" onclick="adminUserAction('set_expiry')" data-t="adm_btn_exp">Set
Exp</button>
<button class="btn-secondary" onclick="adminUserAction('set_limit')" data-t="adm_btn_limit">Set
GB</button>
<button class="btn-secondary" onclick="adminUserAction('set_plan')" data-t="adm_btn_plan">Plan</button>
<button class="btn-secondary" onclick="adminUserAction('toggle_status')"
data-t="adm_btn_toggle">Toggle</button>
<button class="btn-secondary" onclick="adminUserAction('reset_traffic')"
data-t="adm_btn_reset">Reset</button>
<button class="btn-error" style="grid-column:span 2" onclick="adminUserAction('delete_sub')"
data-t="adm_btn_delete">Delete
Sub</button>
</div>
</div>
</template>
<template id="admin-promos">
<button class="btn-primary" style="margin-bottom:16px;" onclick="openCreatePromoModal()"
data-t="adm_create_promo">+ Create Promo</button>
<div id="admin-promos-list" class="admin-list"></div>
</template>
<template id="admin-broadcast">
<div class="m3-card">
<h3 data-t="adm_broadcast">Broadcast</h3>
<p class="subtitle" style="margin-bottom:16px;" data-t="adm_broadcast_msg">Send message to all users</p>
<textarea id="broadcast-msg" class="glass-input"
style="height:120px; border-radius:12px; margin-bottom:16px;" placeholder="Message..."
data-tp="ph_message"></textarea>
<button class="btn-primary" onclick="sendBroadcast()" data-t="adm_send">Send</button>
</div>
</template>
<!-- JS -->
<script src="js/app.js"></script>
</body>

File diff suppressed because it is too large Load Diff