Update WebApp

This commit is contained in:
2026-01-09 22:21:26 +03:00
parent cc272a9753
commit 32d0f98a6e
9 changed files with 1056 additions and 512 deletions

4
.env
View File

@@ -1,9 +1,11 @@
BOT_TOKEN=8406127231:AAG5m0Ft0UUyTW2KI-jwYniXtIRcbSdlxf8 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_URL=http://144.31.66.170:7575/
MARZBAN_USERNAME=admin MARZBAN_USERNAME=admin
MARZBAN_PASSWORD=rY4tU8hX4nqF MARZBAN_PASSWORD=rY4tU8hX4nqF
BASE_URL=https://proxy.stellarisei.ru/
# Оставьте пустым для использования SQLite (создаст файл bot.db) # Оставьте пустым для использования SQLite (создаст файл bot.db)
# DATABASE_URL=postgresql://user:password@localhost/vpnbot # DATABASE_URL=postgresql://user:password@localhost/vpnbot
ADMIN_IDS=583602906 ADMIN_IDS=583602906
PROVIDER_TOKEN= PROVIDER_TOKEN=

1
.gitignore vendored
View File

@@ -1,2 +1 @@
bot.db bot.db
.env

View File

@@ -11,7 +11,9 @@ CONFIG = {
"DATABASE_URL": os.getenv("DATABASE_URL"), "DATABASE_URL": os.getenv("DATABASE_URL"),
"ADMIN_IDS": [int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(",") if i.strip()], "ADMIN_IDS": [int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(",") if i.strip()],
"PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""), "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 = { PLANS = {

View File

@@ -5,8 +5,8 @@ def main_keyboard(is_admin: bool = False, has_active_sub: bool = False) -> Inlin
buttons = [] buttons = []
# Web App Button # Web App Button
if CONFIG["BASE_URL"]: if CONFIG["WEB_APP_URL"]:
buttons.append([InlineKeyboardButton(text="🚀 Открыть приложение", web_app=WebAppInfo(url=CONFIG["BASE_URL"]))]) buttons.append([InlineKeyboardButton(text="🚀 Открыть приложение", web_app=WebAppInfo(url=CONFIG["WEB_APP_URL"]))])
# Если подписки нет (или истекла), показываем кнопку покупки на главном # Если подписки нет (или истекла), показываем кнопку покупки на главном
if not has_active_sub: if not has_active_sub:

16
main.py
View File

@@ -69,7 +69,7 @@ async def main():
from server import app as web_app from server import app as web_app
import uvicorn import uvicorn
web_app.state.bot = bot 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) server = uvicorn.Server(config)
except ImportError: except ImportError:
logger.error("Could not import server or uvicorn") logger.error("Could not import server or uvicorn")
@@ -80,19 +80,25 @@ async def main():
# Set Menu Button # Set Menu Button
from aiogram.types import MenuButtonWebApp, WebAppInfo from aiogram.types import MenuButtonWebApp, WebAppInfo
if CONFIG["BASE_URL"]: if CONFIG["WEB_APP_URL"]:
try: try:
await bot.set_chat_menu_button( 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: except Exception as e:
logger.error(f"Failed to set menu button: {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!") logger.info("Bot started!")
if server: 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( await asyncio.gather(
dp.start_polling(bot), dp.start_polling(bot),
server.serve() server.serve()

View File

@@ -8,7 +8,8 @@ import logging
import json import json
from database import db from database import db
from config import PLANS from config import PLANS, CONFIG
from marzban import marzban
# Setup logging # Setup logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -30,7 +31,6 @@ async def startup():
@app.get("/api/plans") @app.get("/api/plans")
async def get_plans(): async def get_plans():
# Convert PLANS dict to list for easier frontend consumption
plans_list = [] plans_list = []
for pid, p in PLANS.items(): for pid, p in PLANS.items():
plans_list.append({ plans_list.append({
@@ -45,32 +45,55 @@ from pydantic import BaseModel
class BuyPlanRequest(BaseModel): class BuyPlanRequest(BaseModel):
user_id: int user_id: int
plan_id: str 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") @app.post("/api/create-invoice")
async def create_invoice(req: BuyPlanRequest, request: Request): async def create_invoice(req: BuyPlanRequest, request: Request):
bot = getattr(request.app.state, "bot", None) bot = getattr(request.app.state, "bot", None)
if not bot: 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) plan = PLANS.get(req.plan_id)
if not plan: if not plan:
return JSONResponse(status_code=404, content={"error": "Plan not found"}) 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'] 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: try:
invoice_link = await bot.create_invoice_link( invoice_link = await bot.create_invoice_link(
title=f"Sub: {plan['name']}", title=f"Sub: {plan['name']}",
description=f"{plan['data_limit']}GB / {plan['days']} days", 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 provider_token="", # Empty for Stars
currency="XTR", currency="XTR",
prices=[LabeledPrice(label=plan['name'], amount=price)] prices=[LabeledPrice(label=plan['name'], amount=final_price)]
) )
return {"invoice_link": invoice_link} return {"invoice_link": invoice_link}
except Exception as e: except Exception as e:
@@ -86,7 +109,7 @@ async def get_user_stats(user_id: int):
sub_until = user['subscription_until'] sub_until = user['subscription_until']
days_left = 0 days_left = 0
status = "Inactive" status = "Inactive"
expire_str = "-" expire_str = "No active subscription"
if sub_until: if sub_until:
if isinstance(sub_until, str): if isinstance(sub_until, str):
@@ -96,7 +119,7 @@ async def get_user_stats(user_id: int):
pass pass
if isinstance(sub_until, datetime): 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(): if sub_until > datetime.now():
delta = sub_until - datetime.now() delta = sub_until - datetime.now()
days_left = delta.days days_left = delta.days
@@ -104,16 +127,40 @@ async def get_user_stats(user_id: int):
else: else:
status = "Expired" 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 { return {
"status": status, "status": status,
"days_left": days_left, "days_left": days_left,
"expire_date": expire_str, "expire_date": expire_str,
"data_usage": user['data_limit'] or 0, "data_limit_gb": user['data_limit'] or 0,
"plan": "Custom" "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) # Serve Static Files (must be last)
app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static") app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static")
if __name__ == "__main__": 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"])

View File

@@ -1,331 +1,187 @@
:root { :root {
--bg-color: #050510; /* MD3 Color Tokens - Dark Theme (Violet/Deep Space) */
--glass-bg: rgba(255, 255, 255, 0.05); --md-sys-color-background: #09090b;
--glass-border: rgba(255, 255, 255, 0.1); --md-sys-color-on-background: #e2e2e6;
--glass-highlight: rgba(255, 255, 255, 0.15);
--primary: #6366f1; --md-sys-color-surface: #0f1115;
--primary-glow: rgba(99, 102, 241, 0.5); /* Main card bg */
--text-main: #ffffff; --md-sys-color-surface-dim: #09090b;
--text-muted: #94a3b8; /* App bg */
--radius: 16px; --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; --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; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
-webkit-tap-highlight-color: transparent;
} }
body { body {
background-color: var(--bg-color); background-color: var(--md-sys-color-background);
color: var(--text-main); color: var(--md-sys-color-on-background);
font-family: var(--font-main); font-family: var(--font-main);
overflow: hidden; font-size: 16px;
height: 100vh; min-height: 100vh;
display: flex; padding-bottom: 80px;
justify-content: center; /* Space for bottom nav */
align-items: center;
} }
/* Background Animation */
#stars-container { #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; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: -1; 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 { /* Typography */
display: flex; h1 {
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;
font-size: 32px; font-size: 32px;
margin-bottom: 16px;
}
.info {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600; font-weight: 600;
font-size: 14px; color: var(--md-sys-color-on-background);
letter-spacing: -0.5px;
margin-bottom: 4px;
} }
.status { h2 {
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 {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 500;
margin-bottom: 8px; color: var(--md-sys-color-on-background);
} }
.plan-price { .subtitle {
font-size: 32px; color: var(--md-sys-color-outline);
font-weight: 800; font-size: 14px;
color: var(--primary); font-weight: 400;
margin-bottom: 24px;
} }
.plan-features { /* Layout */
list-style: none; .app-layout {
margin-bottom: 24px; margin: 0 auto;
width: 100%; min-height: 100vh;
} }
.plan-features li { .sidebar {
padding: 8px 0; 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); border-bottom: 1px solid var(--glass-border);
color: var(--text-muted);
} }
/* Animations */ .logo-mini {
@keyframes fadeIn { 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 { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(15px);
} }
to { to {
@@ -334,40 +190,455 @@ header {
} }
} }
.view-container>* { /* MD3 Cards */
animation: fadeIn 0.4s ease-out; .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 */ /* Stats Ring */
@media (max-width: 768px) { .status-ring {
.app-container { width: 120px;
width: 100vw; height: 120px;
height: 100vh; margin-bottom: 16px;
border-radius: 0; align-self: center;
flex-direction: column; }
.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 { .sidebar {
width: 100%; display: flex;
height: auto; 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; flex-direction: row;
padding: 12px; width: 100%;
align-items: center; height: 56px;
justify-content: space-between; 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 { .sidebar .nav-item:hover {
display: none; background: rgba(255, 255, 255, 0.03);
/* Mobile nav needs a toggle, simplifying for now */
} }
.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; padding: 0;
border: none; width: 24px;
margin: 0; height: 24px;
box-sizing: border-box;
border-radius: 0;
} }
.stats-grid { /* Hide FAB background on desktop sidebar if we reuse the same button */
grid-template-columns: 1fr; .sidebar .fab-bg {
background: transparent;
width: auto;
height: auto;
box-shadow: none;
display: contents;
/* Treat children as direct children of button */
} }
} }

View File

@@ -3,128 +3,234 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Marzban Bot Dashboard</title> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
<script src="https://telegram.org/js/telegram-web-app.js"></script> rel="stylesheet">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css?v=2">
<script src="https://unpkg.com/lucide@latest"></script> <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> </head>
<body> <body>
<div id="stars-container"></div> <div id="stars-container"></div>
<div class="glow-overlay"></div>
<div class="app-container"> <div class="app-layout">
<!-- Sidebar (Desktop) -->
<aside class="sidebar glass"> <aside class="sidebar glass">
<div class="logo"> <div class="logo">
<i data-lucide="rocket" class="logo-icon"></i> <i data-lucide="rocket" class="logo-icon"></i>
<span>CometBot</span> <span>CometBot</span>
</div> </div>
<nav> <nav class="nav-menu">
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')"> <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>
<button class="nav-item" data-page="shop" onclick="router('shop')"> <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>
<button class="nav-item" data-page="profile" onclick="router('profile')"> <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> </button>
</nav> </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> </aside>
<main class="content"> <!-- Main Content -->
<header class="glass"> <main class="content-area">
<h1 id="page-title">Dashboard</h1> <header class="mobile-header glass">
<div class="actions"> <div class="logo-mini">
<button class="icon-btn"><i data-lucide="bell"></i></button> <i data-lucide="rocket"></i> Comet
<button class="icon-btn theme-toggle"><i data-lucide="sun"></i></button> </div>
<div class="user-chip" id="header-user">
<div class="avatar-xs" id="header-avatar">U</div>
</div> </div>
</header> </header>
<div id="app-view" class="view-container"> <div id="app-view" class="view-container">
<!-- Dynamic Content Loaded Here -->
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</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> </main>
</div> </div>
<!-- Templates for Views --> <!-- VIEW TEMPLATES -->
<!-- Dashboard -->
<template id="view-dashboard"> <template id="view-dashboard">
<div class="stats-grid"> <div class="view-header">
<div class="card glass stat-card"> <h1>Overview</h1>
<div class="icon-box <color>"><i data-lucide="activity"></i></div> <p class="subtitle">Welcome back, <span id="user-name">User</span></p>
<div class="stat-info"> </div>
<h3>Status</h3>
<p class="value" id="dash-status">Active</p> <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> </div>
<div class="card glass stat-card"> <div class="status-details">
<div class="icon-box"><i data-lucide="calendar"></i></div> <div class="detail-item">
<div class="stat-info"> <span class="label">Status</span>
<h3>Days Left</h3> <span class="val status-badge" id="dash-status">Checking...</span>
<p class="value" id="dash-days">0</p>
</div> </div>
</div> <div class="detail-item">
<div class="card glass stat-card"> <span class="label">Plan Details</span>
<div class="icon-box"><i data-lucide="wifi"></i></div> <span class="val" id="dash-limit">0 GB Total</span>
<div class="stat-info"> </div>
<h3>Data Usage</h3> <div class="detail-item">
<p class="value" id="dash-data">0 GB</p> <span class="label">Expires</span>
<span class="val" id="dash-expire">-</span>
</div> </div>
</div> </div>
</div> </div>
<div class="section-title"> <div class="quick-actions">
<h2>Your Subscription</h2> <button class="action-card glass" onclick="router('shop')">
</div> <div class="icon-circle pop"><i data-lucide="zap"></i></div>
<div class="card glass sub-details"> <span>Extend Plan</span>
<div class="detail-row"> </button>
<span>Plan</span> <button class="action-card glass" onclick="router('subscription')">
<strong id="sub-plan-name">Free Tier</strong> <div class="icon-circle"><i data-lucide="qr-code"></i></div>
</div> <span>Connect</span>
<div class="detail-row"> </button>
<span>Expires</span>
<strong id="sub-expire-date">-</strong>
</div>
<button class="btn-primary full-width" onclick="router('shop')">Upgrade Plan</button>
</div> </div>
</template> </template>
<!-- Shop -->
<template id="view-shop"> <template id="view-shop">
<div class="plans-grid" id="plans-container"> <div class="view-header">
<!-- Plans will be injected here --> <h1>Shop</h1>
<p class="subtitle">Choose your plan</p>
</div>
<div class="plans-list" id="plans-container">
<!-- Injected -->
</div> </div>
</template> </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"> <template id="view-profile">
<div class="card glass profile-card"> <div class="view-header">
<div class="profile-header"> <h1>Profile</h1>
<div class="big-avatar">U</div> </div>
<h2>User Profile</h2>
<p>Telegram ID: <span id="profile-tg-id">123456</span></p> <div class="card glass profile-main">
</div> <div class="big-avatar" id="profile-avatar">U</div>
<div class="form-group"> <h2 id="profile-name">User</h2>
<label>Username</label> <p id="profile-id" class="id-badge">ID: 000000</p>
<input type="text" id="profile-username" readonly value="@username" class="glass-input"> </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')">
<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> </div>
</template> </template>
<script src="js/background.js"></script> <script src="js/background.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script>
lucide.createIcons();
</script>
</body> </body>
</html> </html>

View File

@@ -1,183 +1,294 @@
// Navigation Router // Navigation
function router(pageName) { function router(pageName) {
const viewContainer = document.getElementById('app-view'); const viewContainer = document.getElementById('app-view');
const title = document.getElementById('page-title'); const template = document.getElementById(`view-${pageName}`);
const navItems = document.querySelectorAll('.nav-item');
// Update Nav // Update Nav State
navItems.forEach(item => { document.querySelectorAll('.nav-item').forEach(item => {
if (item.dataset.page === pageName) item.classList.add('active'); if (item.dataset.page === pageName) item.classList.add('active');
else item.classList.remove('active'); else item.classList.remove('active');
}); });
// Set Title // Swap View
title.textContent = pageName.charAt(0).toUpperCase() + pageName.slice(1);
// Load View
const template = document.getElementById(`view-${pageName}`);
if (template) { if (template) {
viewContainer.innerHTML = ''; viewContainer.innerHTML = '';
viewContainer.appendChild(template.content.cloneNode(true)); 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'; const API_BASE = '/api';
let currentState = {
user: null,
subUrl: ""
};
// Telegram Integration // Telegram Init
let tgUser = null; const tg = window.Telegram?.WebApp;
if (window.Telegram && window.Telegram.WebApp) { if (tg) {
const tg = window.Telegram.WebApp;
tg.ready(); tg.ready();
tgUser = tg.initDataUnsafe?.user;
// Theme sync
if (tg.colorScheme === 'dark') document.body.classList.add('dark');
// Expand
tg.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 // Dev Mock
if (!tgUser) { if (!currentState.user) {
console.warn("No Telegram user detected, using mock user"); currentState.user = { id: 123456789, first_name: 'Dev', username: 'developer' };
tgUser = { id: 123456789, first_name: 'Test', username: 'testuser' };
} }
// Update UI with User Info // Initial UI Setup
const sidebarName = document.getElementById('sidebar-name'); const headerAvatar = document.getElementById('header-avatar');
const sidebarAvatar = document.getElementById('sidebar-avatar'); if (headerAvatar) {
if (sidebarName) sidebarName.textContent = tgUser.first_name || tgUser.username; headerAvatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
if (sidebarAvatar) sidebarAvatar.textContent = (tgUser.first_name || 'U')[0].toUpperCase(); }
// ------ PAGE LOGIC ------
async function loadDashboard() { async function loadDashboard() {
document.getElementById('user-name').textContent = currentState.user.first_name;
try { try {
const res = await fetch(`${API_BASE}/user/${tgUser.id}`); const res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json(); const data = await res.json();
const statusEl = document.getElementById('dash-status'); if (data.error) throw new Error(data.error);
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 (statusEl) statusEl.textContent = data.status; // Update Text
if (daysEl) daysEl.textContent = data.days_left; document.getElementById('dash-status').textContent = data.status;
if (dataEl) dataEl.textContent = `${data.data_usage || 0} GB`; document.getElementById('dash-limit').textContent = `${data.data_limit_gb} GB`;
if (planEl) planEl.textContent = data.plan; document.getElementById('dash-expire').textContent = data.expire_date;
if (expireEl) expireEl.textContent = data.expire_date; document.getElementById('dash-data-left').textContent = data.used_traffic_gb;
// Colorize status // Progress Ring
if (data.status === 'Active') { const circle = document.getElementById('data-ring');
document.querySelector('.stat-info .value').style.color = '#4ade80'; if (circle) {
} else { const limit = data.data_limit_gb || 100; // avoid div by zero
document.querySelector('.stat-info .value').style.color = '#f87171'; 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) { } catch (e) {
console.error(e); console.error(e);
// Show error state? document.getElementById('dash-status').textContent = 'Error';
} }
} }
async function loadShop() { async function loadShop() {
const container = document.getElementById('plans-container'); const container = document.getElementById('plans-container');
if (!container) return; container.innerHTML = '<div class="loading-spinner"></div>';
container.innerHTML = '<div class="loading-spinner">Loading plans...</div>';
try { try {
const res = await fetch(`${API_BASE}/plans`); const res = await fetch(`${API_BASE}/plans`);
if (!res.ok) throw new Error("Failed to fetch plans");
const plans = await res.json(); const plans = await res.json();
container.innerHTML = ''; container.innerHTML = '';
plans.forEach(plan => { plans.forEach(plan => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card glass plan-card'; card.className = 'glass plan-card plan-item'; // plan-item for animation
// Features list generation
const features = [
`${plan.data_limit} GB Data`,
`${plan.days} Days`,
'High Speed'
];
card.innerHTML = ` card.innerHTML = `
<div class="plan-name">${plan.name}</div> <div class="plan-header">
<div class="plan-price">${plan.price} XTR</div> <span class="plan-title">${plan.name}</span>
<ul class="plan-features"> <span class="plan-price">${plan.price} ⭐️</span>
${features.map(f => `<li>${f}</li>`).join('')} </div>
</ul> <div class="plan-specs">
<button class="btn-primary" onclick="buyPlan('${plan.id}')">Buy for ${plan.price}</button> <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); container.appendChild(card);
}); });
lucide.createIcons();
} catch (e) { } catch (e) {
container.innerHTML = 'Error loading plans.'; container.textContent = "Failed to load plans.";
} }
} }
async function buyPlan(planId) { async function initPayment(planId) {
if (!window.Telegram || !window.Telegram.WebApp) { if (!tg) return alert("Open in Telegram");
alert("Payment only works inside Telegram!");
return;
}
const btn = document.activeElement; // Simple loader on button
const originalText = btn.innerText; const btn = event.target;
btn.innerText = 'Creating Invoice...'; const oldText = btn.innerText;
btn.innerText = 'Creating..';
btn.disabled = true; btn.disabled = true;
try { 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`, { const res = await fetch(`${API_BASE}/create-invoice`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(body)
user_id: tgUser.id,
plan_id: planId
})
}); });
const data = await res.json(); const data = await res.json();
if (data.invoice_link) { if (data.invoice_link) {
window.Telegram.WebApp.openInvoice(data.invoice_link, (status) => { tg.openInvoice(data.invoice_link, (status) => {
if (status === 'paid') { if (status === 'paid') {
window.Telegram.WebApp.showAlert('Payment Successful! Subscription activated.'); tg.showAlert('Successful Payment!');
router('dashboard'); router('dashboard');
} else if (status === 'cancelled') {
// User cancelled
} else {
window.Telegram.WebApp.showAlert('Payment failed or pending.');
} }
}); });
} else { } else {
window.Telegram.WebApp.showAlert('Error creating invoice: ' + data.error); tg.showAlert('Error: ' + (data.error || 'Unknown'));
} }
} catch (e) { } catch (e) {
window.Telegram.WebApp.showAlert('Network error'); tg.showAlert('Network error');
console.error(e);
} finally { } finally {
btn.innerText = originalText; btn.innerText = oldText;
btn.disabled = false; btn.disabled = false;
} }
} }
async function loadProfile() { async function loadSubscription() {
document.getElementById('profile-tg-id').textContent = tgUser.id; const linkEl = document.getElementById('config-link');
document.getElementById('profile-username').value = '@' + (tgUser.username || 'unknown'); 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 function copyConfig() {
router('dashboard'); 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');
});