Update WebApp
This commit is contained in:
4
.env
4
.env
@@ -1,9 +1,11 @@
|
||||
BOT_TOKEN=8406127231:AAG5m0Ft0UUyTW2KI-jwYniXtIRcbSdlxf8
|
||||
WEB_APP_URL=https://app.stellarisei.ru/
|
||||
BASE_URL=https://proxy.stellarisei.ru/
|
||||
MARZBAN_URL=http://144.31.66.170:7575/
|
||||
MARZBAN_USERNAME=admin
|
||||
MARZBAN_PASSWORD=rY4tU8hX4nqF
|
||||
BASE_URL=https://proxy.stellarisei.ru/
|
||||
# Оставьте пустым для использования SQLite (создаст файл bot.db)
|
||||
# DATABASE_URL=postgresql://user:password@localhost/vpnbot
|
||||
ADMIN_IDS=583602906
|
||||
PROVIDER_TOKEN=
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1 @@
|
||||
bot.db
|
||||
.env
|
||||
@@ -11,7 +11,9 @@ CONFIG = {
|
||||
"DATABASE_URL": os.getenv("DATABASE_URL"),
|
||||
"ADMIN_IDS": [int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(",") if i.strip()],
|
||||
"PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""),
|
||||
"BASE_URL": os.getenv("BASE_URL"), # Внешний домен (например, https://vpn.example.com)
|
||||
"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)),
|
||||
}
|
||||
|
||||
PLANS = {
|
||||
|
||||
@@ -5,8 +5,8 @@ def main_keyboard(is_admin: bool = False, has_active_sub: bool = False) -> Inlin
|
||||
buttons = []
|
||||
|
||||
# Web App Button
|
||||
if CONFIG["BASE_URL"]:
|
||||
buttons.append([InlineKeyboardButton(text="🚀 Открыть приложение", web_app=WebAppInfo(url=CONFIG["BASE_URL"]))])
|
||||
if CONFIG["WEB_APP_URL"]:
|
||||
buttons.append([InlineKeyboardButton(text="🚀 Открыть приложение", web_app=WebAppInfo(url=CONFIG["WEB_APP_URL"]))])
|
||||
|
||||
# Если подписки нет (или истекла), показываем кнопку покупки на главном
|
||||
if not has_active_sub:
|
||||
|
||||
16
main.py
16
main.py
@@ -69,7 +69,7 @@ async def main():
|
||||
from server import app as web_app
|
||||
import uvicorn
|
||||
web_app.state.bot = bot
|
||||
config = uvicorn.Config(web_app, host="0.0.0.0", port=8000, log_level="info")
|
||||
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")
|
||||
@@ -80,19 +80,25 @@ async def main():
|
||||
|
||||
# Set Menu Button
|
||||
from aiogram.types import MenuButtonWebApp, WebAppInfo
|
||||
if CONFIG["BASE_URL"]:
|
||||
if CONFIG["WEB_APP_URL"]:
|
||||
try:
|
||||
await bot.set_chat_menu_button(
|
||||
menu_button=MenuButtonWebApp(text="🚀 Dashboard", web_app=WebAppInfo(url=CONFIG["BASE_URL"]))
|
||||
menu_button=MenuButtonWebApp(text="🚀 Dashboard", web_app=WebAppInfo(url=CONFIG["WEB_APP_URL"]))
|
||||
)
|
||||
logger.info(f"Menu button set to {CONFIG['BASE_URL']}")
|
||||
logger.info(f"Menu button set to {CONFIG['WEB_APP_URL']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set menu button: {e}")
|
||||
else:
|
||||
# Если URL не задан, сбрасываем кнопку (чтобы не осталась старая ссылка)
|
||||
await bot.delete_chat_menu_button()
|
||||
logger.info("WEB_APP_URL not found, menu button reset to default.")
|
||||
|
||||
logger.info(f"Config: BASE_URL={CONFIG['BASE_URL']}, WEB_APP_URL={CONFIG['WEB_APP_URL']}")
|
||||
|
||||
logger.info("Bot started!")
|
||||
|
||||
if server:
|
||||
logger.info("Starting Web App on port 8000")
|
||||
logger.info(f"Starting Web App on port {CONFIG['WEB_APP_PORT']}")
|
||||
await asyncio.gather(
|
||||
dp.start_polling(bot),
|
||||
server.serve()
|
||||
|
||||
77
server.py
77
server.py
@@ -8,7 +8,8 @@ import logging
|
||||
import json
|
||||
|
||||
from database import db
|
||||
from config import PLANS
|
||||
from config import PLANS, CONFIG
|
||||
from marzban import marzban
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -30,7 +31,6 @@ async def startup():
|
||||
|
||||
@app.get("/api/plans")
|
||||
async def get_plans():
|
||||
# Convert PLANS dict to list for easier frontend consumption
|
||||
plans_list = []
|
||||
for pid, p in PLANS.items():
|
||||
plans_list.append({
|
||||
@@ -45,32 +45,55 @@ from pydantic import BaseModel
|
||||
class BuyPlanRequest(BaseModel):
|
||||
user_id: int
|
||||
plan_id: str
|
||||
promo_code: str = None
|
||||
|
||||
class PromoCheckRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
@app.post("/api/check-promo")
|
||||
async def check_promo_code(req: PromoCheckRequest):
|
||||
promo = await db.get_promo_code(req.code)
|
||||
if not promo:
|
||||
return JSONResponse(status_code=404, content={"error": "Invalid or expired promo code"})
|
||||
|
||||
return {
|
||||
"code": promo["code"],
|
||||
"discount": promo["discount"],
|
||||
"bonus_days": promo["bonus_days"],
|
||||
"is_unlimited": promo["is_unlimited"],
|
||||
"description": f"Discount {promo['discount']}%" + (f" + {promo['bonus_days']} Days" if promo['bonus_days'] else "")
|
||||
}
|
||||
|
||||
@app.post("/api/create-invoice")
|
||||
async def create_invoice(req: BuyPlanRequest, request: Request):
|
||||
bot = getattr(request.app.state, "bot", None)
|
||||
if not bot:
|
||||
return JSONResponse(status_code=500, content={"error": "Bot instance not initialized in app state"})
|
||||
return JSONResponse(status_code=500, content={"error": "Bot instance not initialized"})
|
||||
|
||||
plan = PLANS.get(req.plan_id)
|
||||
if not plan:
|
||||
return JSONResponse(status_code=404, content={"error": "Plan not found"})
|
||||
|
||||
# Determine price in Stars (XTR). Assuming Plan Price in config is in RUB, need conversion or direct usage.
|
||||
# Telegram Stars usually 1 Star ~= 0.013 USD? Or direct mapping.
|
||||
# User's bot code uses currency="XTR" and prices=[LabeledPrice(..., amount=final_price)].
|
||||
# Usually amount is in smallest units? XTR amount is integer number of stars.
|
||||
# Assuming config price IS stars or directly usable.
|
||||
price = plan['price']
|
||||
|
||||
# Validating Promo
|
||||
discount = 0
|
||||
if req.promo_code:
|
||||
promo = await db.get_promo_code(req.promo_code)
|
||||
if promo:
|
||||
discount = promo['discount']
|
||||
|
||||
final_price = int(price * (100 - discount) / 100)
|
||||
if final_price < 1: final_price = 1
|
||||
|
||||
try:
|
||||
invoice_link = await bot.create_invoice_link(
|
||||
title=f"Sub: {plan['name']}",
|
||||
description=f"{plan['data_limit']}GB / {plan['days']} days",
|
||||
payload=f"{req.plan_id}:", # Promo code empty for now
|
||||
payload=f"{req.plan_id}:{req.promo_code or ''}",
|
||||
provider_token="", # Empty for Stars
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=plan['name'], amount=price)]
|
||||
prices=[LabeledPrice(label=plan['name'], amount=final_price)]
|
||||
)
|
||||
return {"invoice_link": invoice_link}
|
||||
except Exception as e:
|
||||
@@ -86,7 +109,7 @@ async def get_user_stats(user_id: int):
|
||||
sub_until = user['subscription_until']
|
||||
days_left = 0
|
||||
status = "Inactive"
|
||||
expire_str = "-"
|
||||
expire_str = "No active subscription"
|
||||
|
||||
if sub_until:
|
||||
if isinstance(sub_until, str):
|
||||
@@ -96,7 +119,7 @@ async def get_user_stats(user_id: int):
|
||||
pass
|
||||
|
||||
if isinstance(sub_until, datetime):
|
||||
expire_str = sub_until.strftime("%Y-%m-%d")
|
||||
expire_str = sub_until.strftime("%d.%m.%Y")
|
||||
if sub_until > datetime.now():
|
||||
delta = sub_until - datetime.now()
|
||||
days_left = delta.days
|
||||
@@ -104,16 +127,40 @@ async def get_user_stats(user_id: int):
|
||||
else:
|
||||
status = "Expired"
|
||||
|
||||
# Fetch detailed stats from Marzban
|
||||
sub_url = ""
|
||||
used_traffic = 0
|
||||
|
||||
if user['marzban_username']:
|
||||
try:
|
||||
m_user = await marzban.get_user(user['marzban_username'])
|
||||
# Check for error in response
|
||||
if isinstance(m_user, dict) and not m_user.get('detail'):
|
||||
used_traffic = m_user.get('used_traffic', 0)
|
||||
sub_url = m_user.get('subscription_url', "")
|
||||
|
||||
# Fix relative URL
|
||||
if sub_url and sub_url.startswith('/'):
|
||||
base = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL']
|
||||
sub_url = f"{base.rstrip('/')}{sub_url}"
|
||||
except Exception as e:
|
||||
logger.error(f"Marzban fetch error: {e}")
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"days_left": days_left,
|
||||
"expire_date": expire_str,
|
||||
"data_usage": user['data_limit'] or 0,
|
||||
"plan": "Custom"
|
||||
"data_limit_gb": user['data_limit'] or 0,
|
||||
"used_traffic_gb": round(used_traffic / (1024**3), 2),
|
||||
"plan": "Premium",
|
||||
"subscription_url": sub_url,
|
||||
"username": user['username'],
|
||||
"marzban_username": user['marzban_username']
|
||||
}
|
||||
|
||||
# Serve Static Files (must be last)
|
||||
app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
from config import CONFIG
|
||||
uvicorn.run(app, host="0.0.0.0", port=CONFIG["WEB_APP_PORT"])
|
||||
|
||||
@@ -1,331 +1,187 @@
|
||||
:root {
|
||||
--bg-color: #050510;
|
||||
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.15);
|
||||
--primary: #6366f1;
|
||||
--primary-glow: rgba(99, 102, 241, 0.5);
|
||||
--text-main: #ffffff;
|
||||
--text-muted: #94a3b8;
|
||||
--radius: 16px;
|
||||
/* MD3 Color Tokens - Dark Theme (Violet/Deep Space) */
|
||||
--md-sys-color-background: #09090b;
|
||||
--md-sys-color-on-background: #e2e2e6;
|
||||
|
||||
--md-sys-color-surface: #0f1115;
|
||||
/* Main card bg */
|
||||
--md-sys-color-surface-dim: #09090b;
|
||||
/* App bg */
|
||||
--md-sys-color-surface-container: #1b1f26;
|
||||
/* Elevated cards */
|
||||
--md-sys-color-surface-container-high: #242933;
|
||||
/* Modal/Nav */
|
||||
|
||||
--md-sys-color-primary: #d0bcff;
|
||||
--md-sys-color-on-primary: #381e72;
|
||||
--md-sys-color-primary-container: #4f378b;
|
||||
--md-sys-color-on-primary-container: #eaddff;
|
||||
|
||||
--md-sys-color-secondary: #ccc2dc;
|
||||
--md-sys-color-on-secondary: #332d41;
|
||||
--md-sys-color-secondary-container: #4a4458;
|
||||
--md-sys-color-on-secondary-container: #e8def8;
|
||||
|
||||
--md-sys-color-tertiary: #efb8c8;
|
||||
--md-sys-color-outline: #938f99;
|
||||
--md-sys-color-outline-variant: #49454f;
|
||||
|
||||
--radius-l: 24px;
|
||||
--radius-pill: 50px;
|
||||
--font-main: 'Outfit', sans-serif;
|
||||
|
||||
/* Effects */
|
||||
--elevation-1: 0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
--elevation-2: 0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
|
||||
--glass-bg: rgba(30, 33, 40, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
/* Faint stroke for premium feel */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
background-color: var(--md-sys-color-background);
|
||||
color: var(--md-sys-color-on-background);
|
||||
font-family: var(--font-main);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
/* Space for bottom nav */
|
||||
}
|
||||
|
||||
/* Background Animation */
|
||||
#stars-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -2;
|
||||
background: radial-gradient(circle at 50% 100%, #17101f 0%, #09090b 100%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.glow-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
|
||||
background: radial-gradient(circle at 80% 10%, rgba(208, 188, 255, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 90%, rgba(79, 55, 139, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
border-right: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: var(--primary);
|
||||
filter: drop-shadow(0 0 8px var(--primary-glow));
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px var(--primary-glow);
|
||||
}
|
||||
|
||||
.user-mini {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.big-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a855f7, #6366f1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.big-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--md-sys-color-on-background);
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.input-glass {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
|
||||
.view-container {
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dashboard Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-info .value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sub-details {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary.full-width {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Shop */
|
||||
.plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
margin-bottom: 24px;
|
||||
.subtitle {
|
||||
color: var(--md-sys-color-outline);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.plan-features {
|
||||
list-style: none;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
/* Layout */
|
||||
.app-layout {
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.plan-features li {
|
||||
padding: 8px 0;
|
||||
.sidebar {
|
||||
display: none;
|
||||
/* Hidden on mobile */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 30px;
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
/* Prevent flex overflow */
|
||||
}
|
||||
|
||||
/* Components: Glass / Surface */
|
||||
.glass {
|
||||
background: var(--md-sys-color-surface-container);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-l);
|
||||
backdrop-filter: blur(10px);
|
||||
/* Less blur for performance, color does work */
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
.mobile-header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(9, 9, 11, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
.logo-mini {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.user-chip .avatar-xs {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* View Container */
|
||||
.view-container {
|
||||
padding: 24px;
|
||||
animation: fadeUp 0.3s cubic-bezier(0.2, 0.0, 0, 1.0);
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(15px);
|
||||
}
|
||||
|
||||
to {
|
||||
@@ -334,40 +190,455 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
.view-container>* {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
/* MD3 Cards */
|
||||
.status-card {
|
||||
background: var(--md-sys-color-surface-container);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-l);
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
flex-direction: column;
|
||||
/* Stats Ring */
|
||||
.status-ring {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 16px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.circular-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.circle-bg {
|
||||
stroke: var(--md-sys-color-surface-container-high);
|
||||
stroke-width: 8;
|
||||
/* Thicker premium look */
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.circle {
|
||||
stroke: var(--md-sys-color-primary);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
transition: stroke-dasharray 1s ease;
|
||||
}
|
||||
|
||||
.ring-content {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring-content .value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.ring-content .unit {
|
||||
font-size: 12px;
|
||||
color: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
/* Details List */
|
||||
.status-details {
|
||||
background: var(--md-sys-color-surface-dim);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: var(--md-sys-color-surface-container);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-l);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.action-card:active {
|
||||
transform: scale(0.98);
|
||||
background: var(--md-sys-color-surface-container-high);
|
||||
}
|
||||
|
||||
.action-card span {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-circle.pop {
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
/* Shop Items */
|
||||
.plans-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
background: var(--md-sys-color-surface-container);
|
||||
border-radius: var(--radius-l);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.plan-specs {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--md-sys-color-outline);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: none;
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--elevation-1);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: none;
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* MD3 Bottom Navigation */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: var(--md-sys-color-surface-container);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
z-index: 100;
|
||||
padding-bottom: 10px;
|
||||
/* Safe area */
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
background: none;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 64px;
|
||||
cursor: pointer;
|
||||
color: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
z-index: 2;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* Active Indicator (Pill) */
|
||||
.nav-item.active {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border-radius: 16px;
|
||||
padding: 4px 20px;
|
||||
width: 64px;
|
||||
box-sizing: content-box;
|
||||
/* Stretch pill */
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Center FAB Override for 'Config' */
|
||||
.nav-item.center-fab {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.nav-item.center-fab .fab-bg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
border-radius: 16px;
|
||||
/* Squircle */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.glass-input {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
/* M3 text field */
|
||||
background: var(--md-sys-color-surface);
|
||||
color: white;
|
||||
padding: 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
border-width: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Profile */
|
||||
.profile-main {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.big-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.id-badge {
|
||||
background: var(--md-sys-color-surface-container-high);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* List Menu */
|
||||
.list-menu {
|
||||
border-radius: var(--radius-l);
|
||||
background: var(--md-sys-color-surface-container);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Material Ripple Effect */
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
transform: scale(0);
|
||||
animation: ripple 0.6s linear;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop Sidebar fix */
|
||||
@media (min-width: 769px) {
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Ensure vertical layout */
|
||||
background: var(--md-sys-color-surface-container);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
width: 280px;
|
||||
/* Wider standard drawer */
|
||||
padding: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-item {
|
||||
flex-direction: row;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
border-radius: 28px;
|
||||
/* Full pill */
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: none;
|
||||
/* Mobile nav needs a toggle, simplifying for now */
|
||||
.sidebar .nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.user-mini {
|
||||
.sidebar .nav-item.active {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
.sidebar .nav-item svg,
|
||||
.sidebar .nav-item.active svg {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
/* Hide FAB background on desktop sidebar if we reuse the same button */
|
||||
.sidebar .fab-bg {
|
||||
background: transparent;
|
||||
width: auto;
|
||||
height: auto;
|
||||
box-shadow: none;
|
||||
display: contents;
|
||||
/* Treat children as direct children of button */
|
||||
}
|
||||
}
|
||||
@@ -3,128 +3,234 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Marzban Bot Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Comet VPN</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<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&display=swap" rel="stylesheet">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<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">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="stars-container"></div>
|
||||
<div class="glow-overlay"></div>
|
||||
|
||||
<div class="app-container">
|
||||
<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>
|
||||
<nav>
|
||||
<nav class="nav-menu">
|
||||
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
|
||||
<i data-lucide="layout-dashboard"></i> Dashboard
|
||||
<i data-lucide="layout-dashboard"></i> <span>Dashboard</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="shop" onclick="router('shop')">
|
||||
<i data-lucide="shopping-bag"></i> Shop
|
||||
<i data-lucide="shopping-bag"></i> <span>Shop</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="subscription" onclick="router('subscription')">
|
||||
<i data-lucide="key"></i> <span>Config</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="profile" onclick="router('profile')">
|
||||
<i data-lucide="user"></i> Profile
|
||||
<i data-lucide="user"></i> <span>Profile</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="user-mini">
|
||||
<div class="avatar" id="sidebar-avatar">U</div>
|
||||
<div class="info">
|
||||
<span class="name" id="sidebar-name">User</span>
|
||||
<span class="status">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<header class="glass">
|
||||
<h1 id="page-title">Dashboard</h1>
|
||||
<div class="actions">
|
||||
<button class="icon-btn"><i data-lucide="bell"></i></button>
|
||||
<button class="icon-btn theme-toggle"><i data-lucide="sun"></i></button>
|
||||
<!-- Main Content -->
|
||||
<main class="content-area">
|
||||
<header class="mobile-header glass">
|
||||
<div class="logo-mini">
|
||||
<i data-lucide="rocket"></i> Comet
|
||||
</div>
|
||||
<div class="user-chip" id="header-user">
|
||||
<div class="avatar-xs" id="header-avatar">U</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="app-view" class="view-container">
|
||||
<!-- Dynamic Content Loaded Here -->
|
||||
<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>
|
||||
</button>
|
||||
<button class="nav-item" data-page="shop" onclick="router('shop')">
|
||||
<i data-lucide="shopping-bag"></i>
|
||||
</button>
|
||||
<button class="nav-item center-fab" data-page="subscription" onclick="router('subscription')">
|
||||
<div class="fab-bg"><i data-lucide="power"></i></div>
|
||||
</button>
|
||||
<button class="nav-item" data-page="profile" onclick="router('profile')">
|
||||
<i data-lucide="user"></i>
|
||||
</button>
|
||||
<button class="nav-item" onclick="openHelp()">
|
||||
<i data-lucide="help-circle"></i>
|
||||
</button>
|
||||
</nav>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Templates for Views -->
|
||||
<!-- VIEW TEMPLATES -->
|
||||
|
||||
<!-- Dashboard -->
|
||||
<template id="view-dashboard">
|
||||
<div class="stats-grid">
|
||||
<div class="card glass stat-card">
|
||||
<div class="icon-box <color>"><i data-lucide="activity"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>Status</h3>
|
||||
<p class="value" id="dash-status">Active</p>
|
||||
<div class="view-header">
|
||||
<h1>Overview</h1>
|
||||
<p class="subtitle">Welcome back, <span id="user-name">User</span></p>
|
||||
</div>
|
||||
|
||||
<div class="status-card glass">
|
||||
<div class="status-ring">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card glass stat-card">
|
||||
<div class="icon-box"><i data-lucide="calendar"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>Days Left</h3>
|
||||
<p class="value" id="dash-days">0</p>
|
||||
<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>
|
||||
<div class="card glass stat-card">
|
||||
<div class="icon-box"><i data-lucide="wifi"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>Data Usage</h3>
|
||||
<p class="value" id="dash-data">0 GB</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
<h2>Your Subscription</h2>
|
||||
</div>
|
||||
<div class="card glass sub-details">
|
||||
<div class="detail-row">
|
||||
<span>Plan</span>
|
||||
<strong id="sub-plan-name">Free Tier</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Expires</span>
|
||||
<strong id="sub-expire-date">-</strong>
|
||||
</div>
|
||||
<button class="btn-primary full-width" onclick="router('shop')">Upgrade Plan</button>
|
||||
<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>
|
||||
</button>
|
||||
<button class="action-card glass" onclick="router('subscription')">
|
||||
<div class="icon-circle"><i data-lucide="qr-code"></i></div>
|
||||
<span>Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shop -->
|
||||
<template id="view-shop">
|
||||
<div class="plans-grid" id="plans-container">
|
||||
<!-- Plans will be injected here -->
|
||||
<div class="view-header">
|
||||
<h1>Shop</h1>
|
||||
<p class="subtitle">Choose your plan</p>
|
||||
</div>
|
||||
<div class="plans-list" id="plans-container">
|
||||
<!-- Injected -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Subscription -->
|
||||
<template id="view-subscription">
|
||||
<div class="view-header">
|
||||
<h1>Connection</h1>
|
||||
<p class="subtitle">Setup your VPN</p>
|
||||
</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 class="acc-item">
|
||||
<div class="acc-head" onclick="toggleAcc(this)">
|
||||
<span>How to connect on iOS?</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Profile -->
|
||||
<template id="view-profile">
|
||||
<div class="card glass profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="big-avatar">U</div>
|
||||
<h2>User Profile</h2>
|
||||
<p>Telegram ID: <span id="profile-tg-id">123456</span></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="profile-username" readonly value="@username" class="glass-input">
|
||||
</div>
|
||||
<div class="view-header">
|
||||
<h1>Profile</h1>
|
||||
</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>
|
||||
|
||||
<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')">
|
||||
<i data-lucide="message-square"></i>
|
||||
<span>Support</span>
|
||||
<i data-lucide="chevron-right" class="arrow"></i>
|
||||
</button>
|
||||
<button class="list-item" onclick="alert('v1.0.0 Comet')">
|
||||
<i data-lucide="info"></i>
|
||||
<span>About</span>
|
||||
<i data-lucide="chevron-right" class="arrow"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="js/background.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,183 +1,294 @@
|
||||
// Navigation Router
|
||||
// Navigation
|
||||
function router(pageName) {
|
||||
const viewContainer = document.getElementById('app-view');
|
||||
const title = document.getElementById('page-title');
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
const template = document.getElementById(`view-${pageName}`);
|
||||
|
||||
// Update Nav
|
||||
navItems.forEach(item => {
|
||||
// Update Nav State
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
if (item.dataset.page === pageName) item.classList.add('active');
|
||||
else item.classList.remove('active');
|
||||
});
|
||||
|
||||
// Set Title
|
||||
title.textContent = pageName.charAt(0).toUpperCase() + pageName.slice(1);
|
||||
|
||||
// Load View
|
||||
const template = document.getElementById(`view-${pageName}`);
|
||||
// Swap View
|
||||
if (template) {
|
||||
viewContainer.innerHTML = '';
|
||||
viewContainer.appendChild(template.content.cloneNode(true));
|
||||
|
||||
// Initialize view specific logic
|
||||
if (pageName === 'dashboard') loadDashboard();
|
||||
if (pageName === 'shop') loadShop();
|
||||
if (pageName === 'profile') loadProfile();
|
||||
|
||||
// Re-init generic UI stuff like icons if new ones added
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// Init Page Logic
|
||||
if (pageName === 'dashboard') loadDashboard();
|
||||
if (pageName === 'shop') loadShop();
|
||||
if (pageName === 'subscription') loadSubscription();
|
||||
if (pageName === 'profile') loadProfile();
|
||||
|
||||
// Lucide Icons
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
// Smooth Scroll Top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Data Fetching
|
||||
// Global State
|
||||
const API_BASE = '/api';
|
||||
let currentState = {
|
||||
user: null,
|
||||
subUrl: ""
|
||||
};
|
||||
|
||||
// Telegram Integration
|
||||
let tgUser = null;
|
||||
if (window.Telegram && window.Telegram.WebApp) {
|
||||
const tg = window.Telegram.WebApp;
|
||||
// Telegram Init
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg) {
|
||||
tg.ready();
|
||||
tgUser = tg.initDataUnsafe?.user;
|
||||
|
||||
// Theme sync
|
||||
if (tg.colorScheme === 'dark') document.body.classList.add('dark');
|
||||
|
||||
// Expand
|
||||
tg.expand();
|
||||
// Wrap in try-catch for header color as it might fail in some versions
|
||||
try { tg.setHeaderColor('#0f172a'); } catch (e) { }
|
||||
|
||||
currentState.user = tg.initDataUnsafe?.user;
|
||||
}
|
||||
|
||||
// Fallback for browser testing
|
||||
if (!tgUser) {
|
||||
console.warn("No Telegram user detected, using mock user");
|
||||
tgUser = { id: 123456789, first_name: 'Test', username: 'testuser' };
|
||||
// Dev Mock
|
||||
if (!currentState.user) {
|
||||
currentState.user = { id: 123456789, first_name: 'Dev', username: 'developer' };
|
||||
}
|
||||
|
||||
// Update UI with User Info
|
||||
const sidebarName = document.getElementById('sidebar-name');
|
||||
const sidebarAvatar = document.getElementById('sidebar-avatar');
|
||||
if (sidebarName) sidebarName.textContent = tgUser.first_name || tgUser.username;
|
||||
if (sidebarAvatar) sidebarAvatar.textContent = (tgUser.first_name || 'U')[0].toUpperCase();
|
||||
// Initial UI Setup
|
||||
const headerAvatar = document.getElementById('header-avatar');
|
||||
if (headerAvatar) {
|
||||
headerAvatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
|
||||
}
|
||||
|
||||
// ------ PAGE LOGIC ------
|
||||
|
||||
async function loadDashboard() {
|
||||
document.getElementById('user-name').textContent = currentState.user.first_name;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/user/${tgUser.id}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch user");
|
||||
const res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
|
||||
const data = await res.json();
|
||||
|
||||
const statusEl = document.getElementById('dash-status');
|
||||
const daysEl = document.getElementById('dash-days');
|
||||
const dataEl = document.getElementById('dash-data');
|
||||
const planEl = document.getElementById('sub-plan-name');
|
||||
const expireEl = document.getElementById('sub-expire-date');
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
if (statusEl) statusEl.textContent = data.status;
|
||||
if (daysEl) daysEl.textContent = data.days_left;
|
||||
if (dataEl) dataEl.textContent = `${data.data_usage || 0} GB`;
|
||||
if (planEl) planEl.textContent = data.plan;
|
||||
if (expireEl) expireEl.textContent = data.expire_date;
|
||||
// Update Text
|
||||
document.getElementById('dash-status').textContent = data.status;
|
||||
document.getElementById('dash-limit').textContent = `${data.data_limit_gb} GB`;
|
||||
document.getElementById('dash-expire').textContent = data.expire_date;
|
||||
document.getElementById('dash-data-left').textContent = data.used_traffic_gb;
|
||||
|
||||
// Colorize status
|
||||
if (data.status === 'Active') {
|
||||
document.querySelector('.stat-info .value').style.color = '#4ade80';
|
||||
} else {
|
||||
document.querySelector('.stat-info .value').style.color = '#f87171';
|
||||
// Progress Ring
|
||||
const circle = document.getElementById('data-ring');
|
||||
if (circle) {
|
||||
const limit = data.data_limit_gb || 100; // avoid div by zero
|
||||
const used = data.used_traffic_gb || 0;
|
||||
const percent = Math.min((used / limit) * 100, 100);
|
||||
|
||||
// stroke-dasharray="current, 100" (since pathLength=100 logic or simply percentage)
|
||||
// standard dasharray for 36 viewbox is approx 100.
|
||||
circle.setAttribute('stroke-dasharray', `${percent}, 100`);
|
||||
circle.style.stroke = percent > 90 ? '#f87171' : '#6366f1';
|
||||
}
|
||||
|
||||
// Save sub url globally
|
||||
currentState.subUrl = data.subscription_url;
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Show error state?
|
||||
document.getElementById('dash-status').textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShop() {
|
||||
const container = document.getElementById('plans-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '<div class="loading-spinner">Loading plans...</div>';
|
||||
container.innerHTML = '<div class="loading-spinner"></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/plans`);
|
||||
if (!res.ok) throw new Error("Failed to fetch plans");
|
||||
const plans = await res.json();
|
||||
|
||||
container.innerHTML = '';
|
||||
plans.forEach(plan => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card glass plan-card';
|
||||
|
||||
// Features list generation
|
||||
const features = [
|
||||
`${plan.data_limit} GB Data`,
|
||||
`${plan.days} Days`,
|
||||
'High Speed'
|
||||
];
|
||||
|
||||
card.className = 'glass plan-card plan-item'; // plan-item for animation
|
||||
card.innerHTML = `
|
||||
<div class="plan-name">${plan.name}</div>
|
||||
<div class="plan-price">${plan.price} XTR</div>
|
||||
<ul class="plan-features">
|
||||
${features.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
<button class="btn-primary" onclick="buyPlan('${plan.id}')">Buy for ${plan.price}</button>
|
||||
<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} GB</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);
|
||||
});
|
||||
lucide.createIcons();
|
||||
|
||||
} catch (e) {
|
||||
container.innerHTML = 'Error loading plans.';
|
||||
container.textContent = "Failed to load plans.";
|
||||
}
|
||||
}
|
||||
|
||||
async function buyPlan(planId) {
|
||||
if (!window.Telegram || !window.Telegram.WebApp) {
|
||||
alert("Payment only works inside Telegram!");
|
||||
return;
|
||||
}
|
||||
async function initPayment(planId) {
|
||||
if (!tg) return alert("Open in Telegram");
|
||||
|
||||
const btn = document.activeElement;
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = 'Creating Invoice...';
|
||||
// Simple loader on button
|
||||
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;
|
||||
// Reset after use or keep active? Bot usually resets. Let's keep it until refresh.
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/create-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: tgUser.id,
|
||||
plan_id: planId
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.invoice_link) {
|
||||
window.Telegram.WebApp.openInvoice(data.invoice_link, (status) => {
|
||||
tg.openInvoice(data.invoice_link, (status) => {
|
||||
if (status === 'paid') {
|
||||
window.Telegram.WebApp.showAlert('Payment Successful! Subscription activated.');
|
||||
tg.showAlert('Successful Payment!');
|
||||
router('dashboard');
|
||||
} else if (status === 'cancelled') {
|
||||
// User cancelled
|
||||
} else {
|
||||
window.Telegram.WebApp.showAlert('Payment failed or pending.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.Telegram.WebApp.showAlert('Error creating invoice: ' + data.error);
|
||||
tg.showAlert('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
window.Telegram.WebApp.showAlert('Network error');
|
||||
console.error(e);
|
||||
tg.showAlert('Network error');
|
||||
} finally {
|
||||
btn.innerText = originalText;
|
||||
btn.innerText = oldText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
document.getElementById('profile-tg-id').textContent = tgUser.id;
|
||||
document.getElementById('profile-username').value = '@' + (tgUser.username || 'unknown');
|
||||
async function loadSubscription() {
|
||||
const linkEl = document.getElementById('config-link');
|
||||
const fetchBtn = document.querySelector('.btn-secondary');
|
||||
|
||||
// If we don't have URL, try to fetch it again via user stats
|
||||
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;
|
||||
|
||||
if (url) {
|
||||
linkEl.textContent = url;
|
||||
// Gen QR
|
||||
const qrContainer = document.getElementById('qrcode-container');
|
||||
qrContainer.innerHTML = '';
|
||||
new QRCode(qrContainer, {
|
||||
text: url,
|
||||
width: 160,
|
||||
height: 160,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
} else {
|
||||
linkEl.textContent = "No active subscription found";
|
||||
document.getElementById('qrcode-container').innerHTML = '<div style="width:160px;height:160px;background:rgba(255,255,255,0.1);border-radius:12px"></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
router('dashboard');
|
||||
function copyConfig() {
|
||||
if (currentState.subUrl) {
|
||||
navigator.clipboard.writeText(currentState.subUrl);
|
||||
if (tg) tg.showAlert("Link copied to clipboard!");
|
||||
else alert("Copied!");
|
||||
} else {
|
||||
if (tg) tg.showAlert("No subscription to copy.");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAcc(header) {
|
||||
const body = header.nextElementSibling;
|
||||
body.classList.toggle('open');
|
||||
const icon = header.querySelector('svg');
|
||||
// Simple rotation logic if needed, or just rely on CSS
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
document.getElementById('profile-name').textContent = currentState.user.first_name;
|
||||
document.getElementById('profile-id').textContent = `ID: ${currentState.user.id}`;
|
||||
|
||||
const avatar = document.getElementById('profile-avatar');
|
||||
avatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
|
||||
}
|
||||
|
||||
async function checkPromo() {
|
||||
const input = document.getElementById('promo-input');
|
||||
const resDiv = document.getElementById('promo-result');
|
||||
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();
|
||||
resDiv.innerHTML = `<div style="color:#4ade80; margin-top:8px">✅ ${data.description}. Apply at checkout!</div>`;
|
||||
currentState.promoCode = data.code; // Save for checkout
|
||||
tg.showAlert(`Promo valid! Discount: ${data.discount}%. Go to Shop to buy.`);
|
||||
} else {
|
||||
resDiv.innerHTML = `<div style="color:#f87171; margin-top:8px">❌ Invalid Code</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
resDiv.textContent = "Error checking promo";
|
||||
}
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
// Simple alert or modal. Using routing for now logic would be better if we had a help page.
|
||||
alert("Support: @hoshimach1");
|
||||
}
|
||||
|
||||
// Material Ripple Init
|
||||
document.addEventListener('click', function (e) {
|
||||
const target = e.target.closest('button, .action-card, .plan-card'); // Select ripple targets
|
||||
if (target) {
|
||||
const circle = document.createElement('span');
|
||||
const diameter = Math.max(target.clientWidth, target.clientHeight);
|
||||
const radius = diameter / 2;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
circle.style.width = circle.style.height = `${diameter}px`;
|
||||
circle.style.left = `${e.clientX - rect.left - radius}px`;
|
||||
circle.style.top = `${e.clientY - rect.top - radius}px`;
|
||||
circle.classList.add('ripple');
|
||||
|
||||
// Remove existing ripples to be clean or append? Append allows rapid clicks.
|
||||
const ripple = target.getElementsByClassName('ripple')[0];
|
||||
if (ripple) {
|
||||
ripple.remove();
|
||||
}
|
||||
|
||||
target.appendChild(circle);
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
router('dashboard');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user