diff --git a/.env b/.env
index 23ab1f4..ded0ed4 100644
--- a/.env
+++ b/.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=
+
diff --git a/.gitignore b/.gitignore
index c06a1af..004c967 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1 @@
-bot.db
-.env
\ No newline at end of file
+bot.db
\ No newline at end of file
diff --git a/config.py b/config.py
index 6097649..17b7b38 100644
--- a/config.py
+++ b/config.py
@@ -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 = {
diff --git a/keyboards.py b/keyboards.py
index 9e5a45b..baae4ef 100644
--- a/keyboards.py
+++ b/keyboards.py
@@ -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:
diff --git a/main.py b/main.py
index 1af7e58..0ead0bd 100644
--- a/main.py
+++ b/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()
diff --git a/server.py b/server.py
index 3327e03..e29ea78 100644
--- a/server.py
+++ b/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"])
diff --git a/web_app/static/css/style.css b/web_app/static/css/style.css
index 8ce85ac..d8885f1 100644
--- a/web_app/static/css/style.css
+++ b/web_app/static/css/style.css
@@ -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 */
}
}
\ No newline at end of file
diff --git a/web_app/static/index.html b/web_app/static/index.html
index c477cb4..0e08793 100644
--- a/web_app/static/index.html
+++ b/web_app/static/index.html
@@ -3,128 +3,234 @@
-
- Marzban Bot Dashboard
+
+ Comet VPN
+
-
-
-
+
+
+
+
-
+
+
-
-
- Dashboard
-
-
-
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
Status
-
Active
+
+
+
+
-
-
-
-
Days Left
-
0
+
+
+ Status
+ Checking...
-
-
-
-
-
Data Usage
-
0 GB
+
+ Plan Details
+ 0 GB Total
+
+
+ Expires
+ -
-
-
Your Subscription
-
-
-
- Plan
- Free Tier
-
-
- Expires
- -
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
Scan availability QR Code
+
+
+
+
+
+
+
+
+
+
+
+
+ How to connect on iOS?
+
+
+
+
+ - Install V2Box from AppStore.
+ - Copy the link above.
+ - Open V2Box, it will detect the link.
+ - Tap "Import" and Connect.
+
+
+
+
+
+ How to connect on Android?
+
+
+
+
+ - Install v2rayNG or Hiddify.
+ - Copy the config link.
+ - Open app -> Import from Clipboard.
+ - Connect (V button).
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
U
+
User
+
ID: 000000
+
+
+
Promo Code
+
+
+
+
+
+
+
-