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
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=

3
.gitignore vendored
View File

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

View File

@@ -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 = {

View File

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

16
main.py
View File

@@ -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()

View File

@@ -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"])

View File

@@ -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 */
}
}

View File

@@ -3,128 +3,234 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marzban Bot Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Comet VPN</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=2">
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
</head>
<body>
<div id="stars-container"></div>
<div class="glow-overlay"></div>
<div class="app-container">
<div class="app-layout">
<!-- Sidebar (Desktop) -->
<aside class="sidebar glass">
<div class="logo">
<i data-lucide="rocket" class="logo-icon"></i>
<span>CometBot</span>
</div>
<nav>
<nav class="nav-menu">
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-dashboard"></i> Dashboard
<i data-lucide="layout-dashboard"></i> <span>Dashboard</span>
</button>
<button class="nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i> Shop
<i data-lucide="shopping-bag"></i> <span>Shop</span>
</button>
<button class="nav-item" data-page="subscription" onclick="router('subscription')">
<i data-lucide="key"></i> <span>Config</span>
</button>
<button class="nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i> Profile
<i data-lucide="user"></i> <span>Profile</span>
</button>
</nav>
<div class="user-mini">
<div class="avatar" id="sidebar-avatar">U</div>
<div class="info">
<span class="name" id="sidebar-name">User</span>
<span class="status">Online</span>
</div>
</div>
</aside>
<main class="content">
<header class="glass">
<h1 id="page-title">Dashboard</h1>
<div class="actions">
<button class="icon-btn"><i data-lucide="bell"></i></button>
<button class="icon-btn theme-toggle"><i data-lucide="sun"></i></button>
<!-- Main Content -->
<main class="content-area">
<header class="mobile-header glass">
<div class="logo-mini">
<i data-lucide="rocket"></i> Comet
</div>
<div class="user-chip" id="header-user">
<div class="avatar-xs" id="header-avatar">U</div>
</div>
</header>
<div id="app-view" class="view-container">
<!-- Dynamic Content Loaded Here -->
<div class="loading-spinner"></div>
</div>
<!-- Bottom Nav (Mobile) -->
<nav class="bottom-nav glass">
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-dashboard"></i>
</button>
<button class="nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i>
</button>
<button class="nav-item center-fab" data-page="subscription" onclick="router('subscription')">
<div class="fab-bg"><i data-lucide="power"></i></div>
</button>
<button class="nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i>
</button>
<button class="nav-item" onclick="openHelp()">
<i data-lucide="help-circle"></i>
</button>
</nav>
</main>
</div>
<!-- Templates for Views -->
<!-- VIEW TEMPLATES -->
<!-- Dashboard -->
<template id="view-dashboard">
<div class="stats-grid">
<div class="card glass stat-card">
<div class="icon-box <color>"><i data-lucide="activity"></i></div>
<div class="stat-info">
<h3>Status</h3>
<p class="value" id="dash-status">Active</p>
<div class="view-header">
<h1>Overview</h1>
<p class="subtitle">Welcome back, <span id="user-name">User</span></p>
</div>
<div class="status-card glass">
<div class="status-ring">
<svg viewBox="0 0 36 36" class="circular-chart">
<path class="circle-bg"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle" id="data-ring" stroke-dasharray="0, 100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="ring-content">
<span class="value" id="dash-data-left">0</span>
<span class="unit">GB Used</span>
</div>
</div>
<div class="card glass stat-card">
<div class="icon-box"><i data-lucide="calendar"></i></div>
<div class="stat-info">
<h3>Days Left</h3>
<p class="value" id="dash-days">0</p>
<div class="status-details">
<div class="detail-item">
<span class="label">Status</span>
<span class="val status-badge" id="dash-status">Checking...</span>
</div>
</div>
<div class="card glass stat-card">
<div class="icon-box"><i data-lucide="wifi"></i></div>
<div class="stat-info">
<h3>Data Usage</h3>
<p class="value" id="dash-data">0 GB</p>
<div class="detail-item">
<span class="label">Plan Details</span>
<span class="val" id="dash-limit">0 GB Total</span>
</div>
<div class="detail-item">
<span class="label">Expires</span>
<span class="val" id="dash-expire">-</span>
</div>
</div>
</div>
<div class="section-title">
<h2>Your Subscription</h2>
</div>
<div class="card glass sub-details">
<div class="detail-row">
<span>Plan</span>
<strong id="sub-plan-name">Free Tier</strong>
</div>
<div class="detail-row">
<span>Expires</span>
<strong id="sub-expire-date">-</strong>
</div>
<button class="btn-primary full-width" onclick="router('shop')">Upgrade Plan</button>
<div class="quick-actions">
<button class="action-card glass" onclick="router('shop')">
<div class="icon-circle pop"><i data-lucide="zap"></i></div>
<span>Extend Plan</span>
</button>
<button class="action-card glass" onclick="router('subscription')">
<div class="icon-circle"><i data-lucide="qr-code"></i></div>
<span>Connect</span>
</button>
</div>
</template>
<!-- Shop -->
<template id="view-shop">
<div class="plans-grid" id="plans-container">
<!-- Plans will be injected here -->
<div class="view-header">
<h1>Shop</h1>
<p class="subtitle">Choose your plan</p>
</div>
<div class="plans-list" id="plans-container">
<!-- Injected -->
</div>
</template>
<!-- Subscription -->
<template id="view-subscription">
<div class="view-header">
<h1>Connection</h1>
<p class="subtitle">Setup your VPN</p>
</div>
<div class="card glass center-content">
<div id="qrcode-container"></div>
<p class="helper-text">Scan availability QR Code</p>
<div class="copy-box" onclick="copyConfig()">
<div class="truncate-text" id="config-link">Loading...</div>
<i data-lucide="copy"></i>
</div>
<div class="sub-actions">
<button class="btn-secondary" onclick="copyConfig()">Copy Link</button>
<button class="btn-primary"
onclick="window.Telegram.WebApp.openLink('https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690')">
Download App
</button>
</div>
</div>
<div class="accordion glass">
<div class="acc-item">
<div class="acc-head" onclick="toggleAcc(this)">
<span>How to connect on iOS?</span>
<i data-lucide="chevron-down"></i>
</div>
<div class="acc-body">
<ol>
<li>Install <b>V2Box</b> from AppStore.</li>
<li>Copy the link above.</li>
<li>Open V2Box, it will detect the link.</li>
<li>Tap "Import" and Connect.</li>
</ol>
</div>
</div>
<div class="acc-item">
<div class="acc-head" onclick="toggleAcc(this)">
<span>How to connect on Android?</span>
<i data-lucide="chevron-down"></i>
</div>
<div class="acc-body">
<ol>
<li>Install <b>v2rayNG</b> or <b>Hiddify</b>.</li>
<li>Copy the config link.</li>
<li>Open app -> Import from Clipboard.</li>
<li>Connect (V button).</li>
</ol>
</div>
</div>
</div>
</template>
<!-- Profile -->
<template id="view-profile">
<div class="card glass profile-card">
<div class="profile-header">
<div class="big-avatar">U</div>
<h2>User Profile</h2>
<p>Telegram ID: <span id="profile-tg-id">123456</span></p>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="profile-username" readonly value="@username" class="glass-input">
</div>
<div class="view-header">
<h1>Profile</h1>
</div>
<div class="card glass profile-main">
<div class="big-avatar" id="profile-avatar">U</div>
<h2 id="profile-name">User</h2>
<p id="profile-id" class="id-badge">ID: 000000</p>
</div>
<div class="section-title">Promo Code</div>
<div class="card glass promo-card">
<input type="text" id="promo-input" placeholder="ENTER CODE" class="glass-input">
<button class="btn-small" onclick="checkPromo()">Apply</button>
</div>
<div id="promo-result"></div>
<div class="list-menu glass">
<button class="list-item" onclick="window.Telegram.WebApp.openLink('https://t.me/hoshimach1')">
<i data-lucide="message-square"></i>
<span>Support</span>
<i data-lucide="chevron-right" class="arrow"></i>
</button>
<button class="list-item" onclick="alert('v1.0.0 Comet')">
<i data-lucide="info"></i>
<span>About</span>
<i data-lucide="chevron-right" class="arrow"></i>
</button>
</div>
</template>
<script src="js/background.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();
</script>
</body>
</html>

View File

@@ -1,183 +1,294 @@
// Navigation Router
// Navigation
function router(pageName) {
const viewContainer = document.getElementById('app-view');
const title = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item');
const template = document.getElementById(`view-${pageName}`);
// Update Nav
navItems.forEach(item => {
// Update Nav State
document.querySelectorAll('.nav-item').forEach(item => {
if (item.dataset.page === pageName) item.classList.add('active');
else item.classList.remove('active');
});
// Set Title
title.textContent = pageName.charAt(0).toUpperCase() + pageName.slice(1);
// Load View
const template = document.getElementById(`view-${pageName}`);
// Swap View
if (template) {
viewContainer.innerHTML = '';
viewContainer.appendChild(template.content.cloneNode(true));
// Initialize view specific logic
if (pageName === 'dashboard') loadDashboard();
if (pageName === 'shop') loadShop();
if (pageName === 'profile') loadProfile();
// Re-init generic UI stuff like icons if new ones added
if (window.lucide) lucide.createIcons();
}
// Init Page Logic
if (pageName === 'dashboard') loadDashboard();
if (pageName === 'shop') loadShop();
if (pageName === 'subscription') loadSubscription();
if (pageName === 'profile') loadProfile();
// Lucide Icons
if (window.lucide) lucide.createIcons();
// Smooth Scroll Top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Data Fetching
// Global State
const API_BASE = '/api';
let currentState = {
user: null,
subUrl: ""
};
// Telegram Integration
let tgUser = null;
if (window.Telegram && window.Telegram.WebApp) {
const tg = window.Telegram.WebApp;
// Telegram Init
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tgUser = tg.initDataUnsafe?.user;
// Theme sync
if (tg.colorScheme === 'dark') document.body.classList.add('dark');
// Expand
tg.expand();
// Wrap in try-catch for header color as it might fail in some versions
try { tg.setHeaderColor('#0f172a'); } catch (e) { }
currentState.user = tg.initDataUnsafe?.user;
}
// Fallback for browser testing
if (!tgUser) {
console.warn("No Telegram user detected, using mock user");
tgUser = { id: 123456789, first_name: 'Test', username: 'testuser' };
// Dev Mock
if (!currentState.user) {
currentState.user = { id: 123456789, first_name: 'Dev', username: 'developer' };
}
// Update UI with User Info
const sidebarName = document.getElementById('sidebar-name');
const sidebarAvatar = document.getElementById('sidebar-avatar');
if (sidebarName) sidebarName.textContent = tgUser.first_name || tgUser.username;
if (sidebarAvatar) sidebarAvatar.textContent = (tgUser.first_name || 'U')[0].toUpperCase();
// Initial UI Setup
const headerAvatar = document.getElementById('header-avatar');
if (headerAvatar) {
headerAvatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
}
// ------ PAGE LOGIC ------
async function loadDashboard() {
document.getElementById('user-name').textContent = currentState.user.first_name;
try {
const res = await fetch(`${API_BASE}/user/${tgUser.id}`);
if (!res.ok) throw new Error("Failed to fetch user");
const res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
const data = await res.json();
const statusEl = document.getElementById('dash-status');
const daysEl = document.getElementById('dash-days');
const dataEl = document.getElementById('dash-data');
const planEl = document.getElementById('sub-plan-name');
const expireEl = document.getElementById('sub-expire-date');
if (data.error) throw new Error(data.error);
if (statusEl) statusEl.textContent = data.status;
if (daysEl) daysEl.textContent = data.days_left;
if (dataEl) dataEl.textContent = `${data.data_usage || 0} GB`;
if (planEl) planEl.textContent = data.plan;
if (expireEl) expireEl.textContent = data.expire_date;
// Update Text
document.getElementById('dash-status').textContent = data.status;
document.getElementById('dash-limit').textContent = `${data.data_limit_gb} GB`;
document.getElementById('dash-expire').textContent = data.expire_date;
document.getElementById('dash-data-left').textContent = data.used_traffic_gb;
// Colorize status
if (data.status === 'Active') {
document.querySelector('.stat-info .value').style.color = '#4ade80';
} else {
document.querySelector('.stat-info .value').style.color = '#f87171';
// Progress Ring
const circle = document.getElementById('data-ring');
if (circle) {
const limit = data.data_limit_gb || 100; // avoid div by zero
const used = data.used_traffic_gb || 0;
const percent = Math.min((used / limit) * 100, 100);
// stroke-dasharray="current, 100" (since pathLength=100 logic or simply percentage)
// standard dasharray for 36 viewbox is approx 100.
circle.setAttribute('stroke-dasharray', `${percent}, 100`);
circle.style.stroke = percent > 90 ? '#f87171' : '#6366f1';
}
// Save sub url globally
currentState.subUrl = data.subscription_url;
} catch (e) {
console.error(e);
// Show error state?
document.getElementById('dash-status').textContent = 'Error';
}
}
async function loadShop() {
const container = document.getElementById('plans-container');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Loading plans...</div>';
container.innerHTML = '<div class="loading-spinner"></div>';
try {
const res = await fetch(`${API_BASE}/plans`);
if (!res.ok) throw new Error("Failed to fetch plans");
const plans = await res.json();
container.innerHTML = '';
plans.forEach(plan => {
const card = document.createElement('div');
card.className = 'card glass plan-card';
// Features list generation
const features = [
`${plan.data_limit} GB Data`,
`${plan.days} Days`,
'High Speed'
];
card.className = 'glass plan-card plan-item'; // plan-item for animation
card.innerHTML = `
<div class="plan-name">${plan.name}</div>
<div class="plan-price">${plan.price} XTR</div>
<ul class="plan-features">
${features.map(f => `<li>${f}</li>`).join('')}
</ul>
<button class="btn-primary" onclick="buyPlan('${plan.id}')">Buy for ${plan.price}</button>
<div class="plan-header">
<span class="plan-title">${plan.name}</span>
<span class="plan-price">${plan.price} ⭐️</span>
</div>
<div class="plan-specs">
<span><i data-lucide="database"></i> ${plan.data_limit} GB</span>
<span><i data-lucide="clock"></i> ${plan.days} Days</span>
</div>
<button class="btn-primary" onclick="initPayment('${plan.id}')">Purchase</button>
`;
container.appendChild(card);
});
lucide.createIcons();
} catch (e) {
container.innerHTML = 'Error loading plans.';
container.textContent = "Failed to load plans.";
}
}
async function buyPlan(planId) {
if (!window.Telegram || !window.Telegram.WebApp) {
alert("Payment only works inside Telegram!");
return;
}
async function initPayment(planId) {
if (!tg) return alert("Open in Telegram");
const btn = document.activeElement;
const originalText = btn.innerText;
btn.innerText = 'Creating Invoice...';
// Simple loader on button
const btn = event.target;
const oldText = btn.innerText;
btn.innerText = 'Creating..';
btn.disabled = true;
try {
const body = {
user_id: currentState.user.id,
plan_id: planId
};
if (currentState.promoCode) {
body.promo_code = currentState.promoCode;
// Reset after use or keep active? Bot usually resets. Let's keep it until refresh.
}
const res = await fetch(`${API_BASE}/create-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: tgUser.id,
plan_id: planId
})
body: JSON.stringify(body)
});
const data = await res.json();
if (data.invoice_link) {
window.Telegram.WebApp.openInvoice(data.invoice_link, (status) => {
tg.openInvoice(data.invoice_link, (status) => {
if (status === 'paid') {
window.Telegram.WebApp.showAlert('Payment Successful! Subscription activated.');
tg.showAlert('Successful Payment!');
router('dashboard');
} else if (status === 'cancelled') {
// User cancelled
} else {
window.Telegram.WebApp.showAlert('Payment failed or pending.');
}
});
} else {
window.Telegram.WebApp.showAlert('Error creating invoice: ' + data.error);
tg.showAlert('Error: ' + (data.error || 'Unknown'));
}
} catch (e) {
window.Telegram.WebApp.showAlert('Network error');
console.error(e);
tg.showAlert('Network error');
} finally {
btn.innerText = originalText;
btn.innerText = oldText;
btn.disabled = false;
}
}
async function loadProfile() {
document.getElementById('profile-tg-id').textContent = tgUser.id;
document.getElementById('profile-username').value = '@' + (tgUser.username || 'unknown');
async function loadSubscription() {
const linkEl = document.getElementById('config-link');
const fetchBtn = document.querySelector('.btn-secondary');
// If we don't have URL, try to fetch it again via user stats
if (!currentState.subUrl) {
try {
const res = await fetch(`${API_BASE}/user/${currentState.user.id}`);
const data = await res.json();
currentState.subUrl = data.subscription_url;
} catch (e) { }
}
const url = currentState.subUrl;
if (url) {
linkEl.textContent = url;
// Gen QR
const qrContainer = document.getElementById('qrcode-container');
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: url,
width: 160,
height: 160,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
} else {
linkEl.textContent = "No active subscription found";
document.getElementById('qrcode-container').innerHTML = '<div style="width:160px;height:160px;background:rgba(255,255,255,0.1);border-radius:12px"></div>';
}
}
// Init
router('dashboard');
function copyConfig() {
if (currentState.subUrl) {
navigator.clipboard.writeText(currentState.subUrl);
if (tg) tg.showAlert("Link copied to clipboard!");
else alert("Copied!");
} else {
if (tg) tg.showAlert("No subscription to copy.");
}
}
function toggleAcc(header) {
const body = header.nextElementSibling;
body.classList.toggle('open');
const icon = header.querySelector('svg');
// Simple rotation logic if needed, or just rely on CSS
}
async function loadProfile() {
document.getElementById('profile-name').textContent = currentState.user.first_name;
document.getElementById('profile-id').textContent = `ID: ${currentState.user.id}`;
const avatar = document.getElementById('profile-avatar');
avatar.textContent = (currentState.user.first_name || 'U')[0].toUpperCase();
}
async function checkPromo() {
const input = document.getElementById('promo-input');
const resDiv = document.getElementById('promo-result');
const code = input.value.trim();
if (!code) return;
try {
const res = await fetch(`${API_BASE}/check-promo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
if (res.ok) {
const data = await res.json();
resDiv.innerHTML = `<div style="color:#4ade80; margin-top:8px">✅ ${data.description}. Apply at checkout!</div>`;
currentState.promoCode = data.code; // Save for checkout
tg.showAlert(`Promo valid! Discount: ${data.discount}%. Go to Shop to buy.`);
} else {
resDiv.innerHTML = `<div style="color:#f87171; margin-top:8px">❌ Invalid Code</div>`;
}
} catch (e) {
resDiv.textContent = "Error checking promo";
}
}
function openHelp() {
// Simple alert or modal. Using routing for now logic would be better if we had a help page.
alert("Support: @hoshimach1");
}
// Material Ripple Init
document.addEventListener('click', function (e) {
const target = e.target.closest('button, .action-card, .plan-card'); // Select ripple targets
if (target) {
const circle = document.createElement('span');
const diameter = Math.max(target.clientWidth, target.clientHeight);
const radius = diameter / 2;
const rect = target.getBoundingClientRect();
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${e.clientX - rect.left - radius}px`;
circle.style.top = `${e.clientY - rect.top - radius}px`;
circle.classList.add('ripple');
// Remove existing ripples to be clean or append? Append allows rapid clicks.
const ripple = target.getElementsByClassName('ripple')[0];
if (ripple) {
ripple.remove();
}
target.appendChild(circle);
}
});
// Start
document.addEventListener('DOMContentLoaded', () => {
router('dashboard');
});