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

-
- - + +
+
+
+ Comet +
+
+
U
-
+ + +
- + + + + + + + + - \ No newline at end of file diff --git a/web_app/static/js/app.js b/web_app/static/js/app.js index 9619576..f504f85 100644 --- a/web_app/static/js/app.js +++ b/web_app/static/js/app.js @@ -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 = '
Loading plans...
'; + container.innerHTML = '
'; 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 = ` -
${plan.name}
-
${plan.price} XTR
-
    - ${features.map(f => `
  • ${f}
  • `).join('')} -
- +
+ ${plan.name} + ${plan.price} ⭐️ +
+
+ ${plan.data_limit} GB + ${plan.days} Days +
+ `; 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 = '
'; + } } -// 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 = `
✅ ${data.description}. Apply at checkout!
`; + currentState.promoCode = data.code; // Save for checkout + tg.showAlert(`Promo valid! Discount: ${data.discount}%. Go to Shop to buy.`); + } else { + resDiv.innerHTML = `
❌ Invalid Code
`; + } + } 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'); +});