Snapshot: full project state
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
out/
|
||||||
|
work/
|
||||||
|
.pio/
|
||||||
|
briefs/
|
||||||
|
dns-backup/
|
||||||
|
archive/
|
||||||
|
*.png
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Quasar — Supernova SMM Reseller Panel
|
||||||
|
|
||||||
|
Cinematic galaxy-themed social-media-marketing reseller panel. Customers deposit
|
||||||
|
Bitcoin via BTCPay, buy followers/likes/views, and fulfillment rides the upstream
|
||||||
|
SMM panel API — the operator keeps the markup.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Flask + SQLite (`supernova.db`), server-rendered Jinja templates
|
||||||
|
- BTCPay webhook deposits (HMAC-verified)
|
||||||
|
- `panel_client.py` — upstream SMM API client (order/fulfill/status)
|
||||||
|
- `deploy.sh` → CT767 (10.30.20.203), nginx + systemd units included
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- `app.py` — routes: auth, deposit, order, dashboard, admin
|
||||||
|
- `templates/` — base, auth, dashboard, deposit, order, services, orders
|
||||||
|
- `supernova.nginx` / `supernova.service` — deploy configs
|
||||||
|
|
||||||
454
app.py
Normal file
454
app.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"""Supernova — a cinematic galaxy-themed SMM reseller panel.
|
||||||
|
|
||||||
|
Customers deposit Bitcoin (BTCPay) into the operator's wallet, then buy social
|
||||||
|
media engagement (followers/likes/views) which the operator fulfils through the
|
||||||
|
upstream SMM panel API. The operator pockets the markup.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import hmac
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import (Flask, request, session, redirect, url_for, render_template,
|
||||||
|
jsonify, abort, flash)
|
||||||
|
from panel_client import PanelClient
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# CONFIG
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
SMM_API_KEY = os.environ.get("SMM_API_KEY", "7f8a4e57d3568202aa18efe91b48c9c8")
|
||||||
|
MARKUP_PCT = float(os.environ.get("MARKUP_PCT", "100")) # 100% = 2x wholesale
|
||||||
|
|
||||||
|
# BTCPay Server (Greenfield)
|
||||||
|
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140")
|
||||||
|
BTCPAY_API_KEY = os.environ.get("BTCPAY_API_KEY", "")
|
||||||
|
BTCPAY_STORE_ID = os.environ.get("BTCPAY_STORE_ID", "")
|
||||||
|
BTCPAY_WEBHOOK_SECRET = os.environ.get("BTCPAY_WEBHOOK_SECRET", "")
|
||||||
|
BTCPAY_PUBLIC = os.environ.get("BTCPAY_PUBLIC", "https://btcpay.thetempleofdoom.com")
|
||||||
|
SITE_URL = os.environ.get("SITE_URL", "https://supernova.thetempleofdoom.com")
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "supernova.db")
|
||||||
|
SERVICE_CACHE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "services.json")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
|
||||||
|
|
||||||
|
panel = PanelClient(SMM_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# DB
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
c = db()
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
balance REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
link TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
cost REAL NOT NULL,
|
||||||
|
panel_order_id INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS deposits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
invoice_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Auth
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def hash_password(pw, salt=None):
|
||||||
|
salt = salt or secrets.token_hex(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000)
|
||||||
|
return f"{salt}${dk.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(pw, stored):
|
||||||
|
salt, _ = stored.split("$", 1)
|
||||||
|
return hmac.compare_digest(hash_password(pw, salt), stored)
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
uid = session.get("uid")
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
c = db()
|
||||||
|
u = c.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
|
c.close()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrap(*a, **kw):
|
||||||
|
if not current_user():
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
return f(*a, **kw)
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Service cache (avoid hitting the panel API every page load)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def get_services(force=False):
|
||||||
|
if not force and os.path.exists(SERVICE_CACHE):
|
||||||
|
if time.time() - os.path.getmtime(SERVICE_CACHE) < 900: # 15 min TTL
|
||||||
|
with open(SERVICE_CACHE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
svcs = panel.services()
|
||||||
|
with open(SERVICE_CACHE, "w") as f:
|
||||||
|
json.dump(svcs, f)
|
||||||
|
return svcs
|
||||||
|
|
||||||
|
|
||||||
|
def wholesale_cost(rate, quantity):
|
||||||
|
return float(rate) * quantity / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def retail_price(rate, quantity):
|
||||||
|
return wholesale_cost(rate, quantity) * (1 + MARKUP_PCT / 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# BTCPay
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def btcpay_headers():
|
||||||
|
return {"Authorization": f"token {BTCPAY_API_KEY}",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def create_invoice(amount_usd, order_id, buyer_email):
|
||||||
|
# price in USD; BTCPay converts to BTC at its rate
|
||||||
|
payload = {
|
||||||
|
"amount": str(round(amount_usd, 2)),
|
||||||
|
"currency": "USD",
|
||||||
|
"checkout": {"redirectURL": SITE_URL + "/dashboard"},
|
||||||
|
"metadata": {"orderId": str(order_id),
|
||||||
|
"buyerEmail": buyer_email},
|
||||||
|
}
|
||||||
|
r = requests.post(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE_ID}/invoices",
|
||||||
|
headers=btcpay_headers(), json=payload, timeout=20, verify=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
inv = r.json()
|
||||||
|
# rewrite checkoutLink from LAN IP to public host (customer-facing)
|
||||||
|
link = inv.get("checkoutLink", "")
|
||||||
|
if link and link.startswith(BTCPAY_URL):
|
||||||
|
inv["checkoutLink"] = BTCPAY_PUBLIC + link[len(BTCPAY_URL):]
|
||||||
|
return inv
|
||||||
|
|
||||||
|
|
||||||
|
def get_invoice(invoice_id):
|
||||||
|
r = requests.get(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE_ID}/invoices/{invoice_id}",
|
||||||
|
headers=btcpay_headers(), timeout=20, verify=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html", user=current_user())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/robots.txt")
|
||||||
|
def robots():
|
||||||
|
body = ("User-agent: *\n"
|
||||||
|
"Allow: /\n"
|
||||||
|
"Sitemap: https://supernova.thetempleofdoom.com/sitemap.xml\n")
|
||||||
|
return body, 200, {"Content-Type": "text/plain"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/sitemap.xml")
|
||||||
|
def sitemap():
|
||||||
|
pages = [
|
||||||
|
("/", "1.0", "weekly"),
|
||||||
|
("/services", "0.9", "daily"),
|
||||||
|
("/signup", "0.5", "monthly"),
|
||||||
|
("/login", "0.3", "monthly"),
|
||||||
|
]
|
||||||
|
urls = "".join(
|
||||||
|
f"<url><loc>https://supernova.thetempleofdoom.com{p}</loc>"
|
||||||
|
f"<changefreq>{freq}</changefreq><priority>{pri}</priority></url>"
|
||||||
|
for p, pri, freq in pages
|
||||||
|
)
|
||||||
|
xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||||
|
f"{urls}</urlset>")
|
||||||
|
return xml, 200, {"Content-Type": "application/xml"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/bg-demo")
|
||||||
|
def bg_demo():
|
||||||
|
theme = request.args.get("theme", "cosmos")
|
||||||
|
themes = ["starfield", "cosmos", "blackhole", "geometric", "nebula", "particles", "waves", "grid"]
|
||||||
|
return render_template("bg-demo.html", user=current_user(), theme=theme, themes=themes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/signup", methods=["GET", "POST"])
|
||||||
|
def signup():
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
pw = request.form.get("password", "")
|
||||||
|
if len(pw) < 8:
|
||||||
|
flash("Password must be at least 8 characters.", "error")
|
||||||
|
return redirect(url_for("signup"))
|
||||||
|
c = db()
|
||||||
|
try:
|
||||||
|
c.execute("INSERT INTO users (email, password_hash, created_at) VALUES (?,?,?)",
|
||||||
|
(email, hash_password(pw), int(time.time())))
|
||||||
|
c.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
c.close()
|
||||||
|
flash("That email is already registered.", "error")
|
||||||
|
return redirect(url_for("signup"))
|
||||||
|
uid = c.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"]
|
||||||
|
c.close()
|
||||||
|
session["uid"] = uid
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
return render_template("auth.html", mode="signup", user=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email", "").strip().lower()
|
||||||
|
pw = request.form.get("password", "")
|
||||||
|
c = db()
|
||||||
|
u = c.execute("SELECT * FROM users WHERE email=?", (email,)).fetchone()
|
||||||
|
c.close()
|
||||||
|
if u and verify_password(pw, u["password_hash"]):
|
||||||
|
session["uid"] = u["id"]
|
||||||
|
nxt = request.args.get("next") or url_for("dashboard")
|
||||||
|
return redirect(nxt)
|
||||||
|
flash("Invalid email or password.", "error")
|
||||||
|
return render_template("auth.html", mode="login", user=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/services")
|
||||||
|
def services():
|
||||||
|
svcs = get_services()
|
||||||
|
# group by category
|
||||||
|
cats = {}
|
||||||
|
for s in svcs:
|
||||||
|
cats.setdefault(s["category"], []).append(s)
|
||||||
|
return render_template("services.html", user=current_user(), cats=cats)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/order/<int:service_id>", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def order(service_id):
|
||||||
|
svcs = {s["service"]: s for s in get_services()}
|
||||||
|
s = svcs.get(service_id)
|
||||||
|
if not s:
|
||||||
|
abort(404)
|
||||||
|
if request.method == "POST":
|
||||||
|
link = request.form.get("link", "").strip()
|
||||||
|
quantity = int(request.form.get("quantity", 0))
|
||||||
|
cost = retail_price(s["rate"], quantity)
|
||||||
|
if not link or quantity < s["min"]:
|
||||||
|
flash(f"Enter a valid link and quantity (min {s['min']}).", "error")
|
||||||
|
return redirect(url_for("order", service_id=service_id))
|
||||||
|
u = current_user()
|
||||||
|
if u["balance"] < cost:
|
||||||
|
flash("Insufficient balance — deposit more BTC first.", "error")
|
||||||
|
return redirect(url_for("deposit"))
|
||||||
|
# charge + place upstream order
|
||||||
|
try:
|
||||||
|
res = panel.place_order(service_id, link, quantity)
|
||||||
|
panel_oid = res.get("order")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Upstream order failed: {e}", "error")
|
||||||
|
return redirect(url_for("order", service_id=service_id))
|
||||||
|
c = db()
|
||||||
|
c.execute("UPDATE users SET balance = balance - ? WHERE id=?",
|
||||||
|
(cost, u["id"]))
|
||||||
|
c.execute("""INSERT INTO orders
|
||||||
|
(user_id, service_id, service_name, link, quantity, cost,
|
||||||
|
panel_order_id, status, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(u["id"], service_id, s["name"], link, quantity, cost,
|
||||||
|
panel_oid, "pending", int(time.time())))
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
flash(f"Order placed! ${cost:.2f} — it'll start delivering shortly.", "success")
|
||||||
|
return redirect(url_for("orders") + "?launched=1")
|
||||||
|
return render_template("order.html", user=current_user(), s=s,
|
||||||
|
markup=MARKUP_PCT)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/orders")
|
||||||
|
@login_required
|
||||||
|
def orders():
|
||||||
|
u = current_user()
|
||||||
|
c = db()
|
||||||
|
rows = c.execute("SELECT * FROM orders WHERE user_id=? ORDER BY id DESC LIMIT 100",
|
||||||
|
(u["id"],)).fetchall()
|
||||||
|
c.close()
|
||||||
|
return render_template("orders.html", user=u, orders=rows)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/dashboard")
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
u = current_user()
|
||||||
|
c = db()
|
||||||
|
orders = c.execute("SELECT * FROM orders WHERE user_id=? ORDER BY id DESC LIMIT 10",
|
||||||
|
(u["id"],)).fetchall()
|
||||||
|
deposits = c.execute("SELECT * FROM deposits WHERE user_id=? ORDER BY id DESC LIMIT 10",
|
||||||
|
(u["id"],)).fetchall()
|
||||||
|
c.close()
|
||||||
|
return render_template("dashboard.html", user=u, orders=orders, deposits=deposits)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/deposit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def deposit():
|
||||||
|
u = current_user()
|
||||||
|
if request.method == "POST":
|
||||||
|
amount = float(request.form.get("amount", 0))
|
||||||
|
if amount <= 0:
|
||||||
|
flash("Enter a valid amount.", "error")
|
||||||
|
return redirect(url_for("deposit"))
|
||||||
|
c = db()
|
||||||
|
cur = c.execute("""INSERT INTO deposits (user_id, amount, status, created_at)
|
||||||
|
VALUES (?,?,'pending',?)""",
|
||||||
|
(u["id"], amount, int(time.time())))
|
||||||
|
c.commit()
|
||||||
|
dep_id = cur.lastrowid
|
||||||
|
c.close()
|
||||||
|
inv = create_invoice(amount, dep_id, u["email"])
|
||||||
|
c = db()
|
||||||
|
c.execute("UPDATE deposits SET invoice_id=? WHERE id=?",
|
||||||
|
(inv["id"], dep_id))
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
return redirect(inv["checkoutLink"])
|
||||||
|
return render_template("deposit.html", user=u)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/webhook/btcpay", methods=["POST"])
|
||||||
|
def btcpay_webhook():
|
||||||
|
# verify signature
|
||||||
|
sig = request.headers.get("BTCPay-Sig", "")
|
||||||
|
body = request.get_data()
|
||||||
|
expected = "sha256=" + hmac.new(BTCPAY_WEBHOOK_SECRET.encode(), body,
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
if BTCPAY_WEBHOOK_SECRET and not hmac.compare_digest(sig, expected):
|
||||||
|
return "bad signature", 401
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
inv_id = data.get("invoiceId")
|
||||||
|
if not inv_id:
|
||||||
|
return "ok", 200
|
||||||
|
|
||||||
|
# re-fetch invoice to confirm status (don't trust the webhook blindly)
|
||||||
|
try:
|
||||||
|
inv = get_invoice(inv_id)
|
||||||
|
except Exception:
|
||||||
|
return "ok", 200
|
||||||
|
if inv.get("status") != "Settled":
|
||||||
|
return "ok", 200
|
||||||
|
|
||||||
|
dep_id = (inv.get("metadata") or {}).get("orderId")
|
||||||
|
amount = float(inv.get("amount", 0))
|
||||||
|
c = db()
|
||||||
|
dep = c.execute("SELECT * FROM deposits WHERE id=?", (dep_id,)).fetchone()
|
||||||
|
if dep and dep["status"] != "credited":
|
||||||
|
c.execute("UPDATE deposits SET status='credited' WHERE id=?", (dep_id,))
|
||||||
|
c.execute("UPDATE users SET balance = balance + ? WHERE id=?",
|
||||||
|
(amount, dep["user_id"]))
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
return "ok", 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/quote/<int:service_id>/<int:quantity>")
|
||||||
|
def api_quote(service_id, quantity):
|
||||||
|
svcs = {s["service"]: s for s in get_services()}
|
||||||
|
s = svcs.get(service_id)
|
||||||
|
if not s:
|
||||||
|
return jsonify({"error": "unknown service"}), 404
|
||||||
|
return jsonify({
|
||||||
|
"quantity": quantity,
|
||||||
|
"wholesale": round(wholesale_cost(s["rate"], quantity), 4),
|
||||||
|
"retail": round(retail_price(s["rate"], quantity), 2),
|
||||||
|
"rate": s["rate"],
|
||||||
|
"min": s["min"],
|
||||||
|
"max": s["max"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/orders-status")
|
||||||
|
@login_required
|
||||||
|
def api_orders_status():
|
||||||
|
"""Live delivery status for the dashboard ticker."""
|
||||||
|
u = current_user()
|
||||||
|
c = db()
|
||||||
|
rows = c.execute("SELECT * FROM orders WHERE user_id=? ORDER BY id DESC LIMIT 8",
|
||||||
|
(u["id"],)).fetchall()
|
||||||
|
c.close()
|
||||||
|
out = []
|
||||||
|
for o in rows:
|
||||||
|
status = o["status"]
|
||||||
|
remains = 0
|
||||||
|
delivered = o["quantity"]
|
||||||
|
if o["panel_order_id"]:
|
||||||
|
try:
|
||||||
|
st = panel.order_status(o["panel_order_id"])
|
||||||
|
status = (st.get("status") or o["status"]).lower().replace(" ", "")
|
||||||
|
remains = int(st.get("remains") or 0)
|
||||||
|
delivered = max(0, o["quantity"] - remains)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out.append({
|
||||||
|
"id": o["id"],
|
||||||
|
"name": o["service_name"],
|
||||||
|
"quantity": o["quantity"],
|
||||||
|
"delivered": delivered,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
return jsonify({"orders": out})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
app.run(host="0.0.0.0", port=5000)
|
||||||
2
credit.sh
Normal file
2
credit.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
sqlite3 /opt/supernova/supernova.db "UPDATE users SET balance=10.00 WHERE email='test@example.com'; UPDATE deposits SET status='credited' WHERE id=2; SELECT email,balance FROM users WHERE email='test@example.com';"
|
||||||
38
deploy.sh
Normal file
38
deploy.sh
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy Supernova to CT767 (10.30.20.203)
|
||||||
|
set -e
|
||||||
|
export SSHPASS='Czapiewski1!'
|
||||||
|
SSH="/opt/homebrew/bin/sshpass -e ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 root@10.30.20.85"
|
||||||
|
SCP="/opt/homebrew/bin/sshpass -e scp -O -o StrictHostKeyChecking=accept-new"
|
||||||
|
CT="pct exec 767 --"
|
||||||
|
|
||||||
|
cd /Users/drjones/quasar-app
|
||||||
|
|
||||||
|
echo "=== packaging app ==="
|
||||||
|
tar czf /tmp/supernova-app.tar.gz app.py panel_client.py templates static
|
||||||
|
|
||||||
|
echo "=== pushing to CT767 ==="
|
||||||
|
$SCP /tmp/supernova-app.tar.gz root@10.30.20.85:/tmp/supernova-app.tar.gz
|
||||||
|
$SCP .env supernova.service supernova.nginx root@10.30.20.85:/tmp/
|
||||||
|
|
||||||
|
echo "=== installing on CT ==="
|
||||||
|
$SSH "
|
||||||
|
$CT bash -c '
|
||||||
|
mkdir -p /opt/supernova
|
||||||
|
tar xzf /tmp/supernova-app.tar.gz -C /opt/supernova
|
||||||
|
cp /tmp/.env /opt/supernova/.env
|
||||||
|
cp /tmp/supernova.service /etc/systemd/system/supernova.service
|
||||||
|
cp /tmp/supernova.nginx /etc/nginx/sites-available/supernova
|
||||||
|
ln -sf /etc/nginx/sites-available/supernova /etc/nginx/sites-enabled/supernova
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable supernova
|
||||||
|
systemctl restart supernova
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "=== verifying ==="
|
||||||
|
sleep 3
|
||||||
|
$SSH "$CT bash -c 'systemctl is-active supernova; curl -s -m 5 http://127.0.0.1:5000/ -o /dev/null -w \"app :5000 -> %{http_code}\n\"; curl -s -m 5 http://127.0.0.1:80/ -o /dev/null -w \"nginx :80 -> %{http_code}\n\"'"
|
||||||
|
echo "=== DONE ==="
|
||||||
16
link-wallet.sh
Normal file
16
link-wallet.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
PW=$(grep -oE "Password=[^;]*" /etc/btcpay/btcpay.config | head -1 | cut -d= -f2)
|
||||||
|
export PGPASSWORD="$PW"
|
||||||
|
PSQL="psql -h localhost -U btcpay -d btcpay -t -A"
|
||||||
|
|
||||||
|
SRC=$($PSQL -c "SELECT \"StoreName\" FROM \"Stores\" WHERE \"DerivationStrategies\" IS NOT NULL AND \"DerivationStrategies\"::text != '{}' ORDER BY \"StoreName\" LIMIT 1")
|
||||||
|
echo "source store: $SRC"
|
||||||
|
|
||||||
|
$PSQL -c "UPDATE \"Stores\" SET \"DerivationStrategies\" = (SELECT \"DerivationStrategies\" FROM \"Stores\" WHERE \"StoreName\"='$SRC'), \"DerivationStrategy\"='BTC-CHAIN' WHERE \"StoreName\"='Supernova'"
|
||||||
|
echo "wallet copy done"
|
||||||
|
|
||||||
|
$PSQL -c "UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(COALESCE(\"StoreBlob\",'{}'), '{networkFeeMode}', '\"Always\"', true) WHERE \"StoreName\"='Supernova'"
|
||||||
|
echo "networkFeeMode set"
|
||||||
|
|
||||||
|
echo "=== verify ==="
|
||||||
|
$PSQL -c "SELECT \"StoreName\", \"DerivationStrategy\", (\"DerivationStrategies\"->'BTC-CHAIN'->>'accountDerivation') IS NOT NULL AS has_xpub FROM \"Stores\" WHERE \"StoreName\"='Supernova'"
|
||||||
32
panel_client.py
Normal file
32
panel_client.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Supernova — SMM reseller panel client.
|
||||||
|
|
||||||
|
Wraps the upstream SMM panel API (PerfectPanel-style v2).
|
||||||
|
Rate is per-1000 units.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://therealblackmarket.net/api/v2"
|
||||||
|
|
||||||
|
|
||||||
|
class PanelClient:
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self.key = api_key
|
||||||
|
|
||||||
|
def _call(self, action, **params):
|
||||||
|
data = {"key": self.key, "action": action}
|
||||||
|
data.update(params)
|
||||||
|
r = requests.post(BASE, data=data, timeout=25)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def balance(self):
|
||||||
|
return self._call("balance")
|
||||||
|
|
||||||
|
def services(self):
|
||||||
|
return self._call("services")
|
||||||
|
|
||||||
|
def place_order(self, service_id, link, quantity):
|
||||||
|
return self._call("add", service=service_id, link=link, quantity=quantity)
|
||||||
|
|
||||||
|
def order_status(self, order_id):
|
||||||
|
return self._call("status", order=order_id)
|
||||||
315
static/app.js
Normal file
315
static/app.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
// Supernova — slow, cinematic starfield + satisfying interactivity.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Starfield (slow, gentle parallax drift)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const canvas = document.getElementById('starfield');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let stars = [], W, H;
|
||||||
|
|
||||||
|
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; }
|
||||||
|
|
||||||
|
function makeStars() {
|
||||||
|
stars = [];
|
||||||
|
const n = Math.min(260, Math.floor(W * H / 6000));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
stars.push({
|
||||||
|
x: Math.random() * W, y: Math.random() * H,
|
||||||
|
r: Math.random() * 1.3 + 0.3,
|
||||||
|
tw: Math.random() * Math.PI * 2, twSpeed: 0.4 + Math.random() * 0.9,
|
||||||
|
drift: 0.04 + Math.random() * 0.18, hue: Math.random(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = 0;
|
||||||
|
function frame() {
|
||||||
|
t += 0.016;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
for (const s of stars) {
|
||||||
|
s.y += s.drift; s.x += s.drift * 0.15;
|
||||||
|
if (s.y > H + 4) { s.y = -4; s.x = Math.random() * W; }
|
||||||
|
if (s.x > W + 4) s.x = -4;
|
||||||
|
const tw = 0.55 + 0.45 * Math.sin(t * s.twSpeed + s.tw);
|
||||||
|
const a = tw * (0.5 + 0.5 * s.r / 1.6);
|
||||||
|
let col = s.hue < 0.72 ? `rgba(207,224,255,${a})`
|
||||||
|
: s.hue < 0.86 ? `rgba(186,160,255,${a})`
|
||||||
|
: s.hue < 0.95 ? `rgba(160,200,255,${a})` : `rgba(255,180,220,${a})`;
|
||||||
|
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = col; ctx.fill();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shootingStar() {
|
||||||
|
const x = Math.random() * W * 0.7 + W * 0.15, y = Math.random() * H * 0.4;
|
||||||
|
const len = 90 + Math.random() * 60, ang = Math.PI * 0.75; let p = 0;
|
||||||
|
const step = () => {
|
||||||
|
p += 0.04;
|
||||||
|
const cx = x + Math.cos(ang) * len * p, cy = y + Math.sin(ang) * len * p;
|
||||||
|
const g = ctx.createLinearGradient(cx, cy, cx - Math.cos(ang) * len * 0.4, cy - Math.sin(ang) * len * 0.4);
|
||||||
|
g.addColorStop(0, 'rgba(255,255,255,0.85)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
ctx.strokeStyle = g; ctx.lineWidth = 1.6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||||
|
ctx.lineTo(cx - Math.cos(ang) * len * 0.4, cy - Math.sin(ang) * len * 0.4); ctx.stroke();
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
step(); setTimeout(shootingStar, 9000 + Math.random() * 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(); makeStars(); requestAnimationFrame(frame);
|
||||||
|
setTimeout(shootingStar, 4000);
|
||||||
|
addEventListener('resize', () => { resize(); makeStars(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Confetti celebration (gentle galaxy burst)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
function celebrate() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999';
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = innerWidth; canvas.height = innerHeight;
|
||||||
|
const colors = ['#7b2ff7', '#2b6cff', '#ff2d95', '#ffffff', '#cfe0ff', '#7cffb0'];
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
parts.push({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: -20 - Math.random() * canvas.height * 0.4,
|
||||||
|
r: Math.random() * 4 + 2,
|
||||||
|
vy: 1.4 + Math.random() * 2.2,
|
||||||
|
vx: (Math.random() - 0.5) * 1.6,
|
||||||
|
rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 0.07,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
star: Math.random() < 0.45,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let frames = 0;
|
||||||
|
function starPath(c, r) {
|
||||||
|
c.beginPath();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const a = (i * 4 * Math.PI) / 5 - Math.PI / 2;
|
||||||
|
const a2 = a + (2 * Math.PI) / 5;
|
||||||
|
c.lineTo(Math.cos(a) * r, Math.sin(a) * r);
|
||||||
|
c.lineTo(Math.cos((a + a2) / 2) * r * 0.4, Math.sin((a + a2) / 2) * r * 0.4);
|
||||||
|
}
|
||||||
|
c.closePath(); c.fill();
|
||||||
|
}
|
||||||
|
function step() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
frames++;
|
||||||
|
for (const p of parts) {
|
||||||
|
p.x += p.vx; p.y += p.vy; p.rot += p.vr; p.vy += 0.015;
|
||||||
|
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
|
||||||
|
ctx.globalAlpha = Math.max(0, 1 - frames / 250);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
if (p.star) starPath(ctx, p.r);
|
||||||
|
else { ctx.beginPath(); ctx.arc(0, 0, p.r, 0, Math.PI * 2); ctx.fill(); }
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
if (frames < 270 && parts.some(p => p.y < canvas.height + 40)) requestAnimationFrame(step);
|
||||||
|
else canvas.remove();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Subtle 3D card tilt (slow, max 4deg)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const cards = document.querySelectorAll('.card');
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.addEventListener('mousemove', e => {
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
card.style.transform = `perspective(900px) rotateX(${(-py * 4).toFixed(2)}deg) rotateY(${(px * 4).toFixed(2)}deg) translateY(-3px)`;
|
||||||
|
});
|
||||||
|
card.addEventListener('mouseleave', () => { card.style.transform = ''; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Scroll reveal (fade + rise, gentle)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const els = document.querySelectorAll('.card, .stat, .seo-block, .section-title');
|
||||||
|
if (!('IntersectionObserver' in window)) return;
|
||||||
|
const io = new IntersectionObserver(entries => {
|
||||||
|
entries.forEach(en => {
|
||||||
|
if (en.isIntersecting) { en.target.classList.add('revealed'); io.unobserve(en.target); }
|
||||||
|
});
|
||||||
|
}, { threshold: 0.08 });
|
||||||
|
els.forEach(el => {
|
||||||
|
el.classList.add('reveal');
|
||||||
|
io.observe(el);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Button ripple
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
const rip = document.createElement('span');
|
||||||
|
const size = Math.max(r.width, r.height) * 1.6;
|
||||||
|
rip.style.cssText = `position:absolute;border-radius:50%;width:${size}px;height:${size}px;` +
|
||||||
|
`left:${e.clientX - r.left - size / 2}px;top:${e.clientY - r.top - size / 2}px;` +
|
||||||
|
`background:radial-gradient(circle,rgba(255,255,255,0.45),rgba(255,255,255,0));` +
|
||||||
|
`transform:scale(0);animation:ripple .6s ease-out forwards;pointer-events:none`;
|
||||||
|
btn.style.position = 'relative'; btn.style.overflow = 'hidden';
|
||||||
|
btn.appendChild(rip);
|
||||||
|
setTimeout(() => rip.remove(), 650);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Quantity slider — animated price counter + value tween
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const slider = document.getElementById('qty');
|
||||||
|
const priceEl = document.getElementById('live-price');
|
||||||
|
const qtyEl = document.getElementById('qty-val');
|
||||||
|
if (!slider || !priceEl) return;
|
||||||
|
const serviceId = parseInt(slider.dataset.service, 10);
|
||||||
|
let shown = 0;
|
||||||
|
|
||||||
|
function render(target, qty) {
|
||||||
|
const start = performance.now(), from = shown, dur = 320;
|
||||||
|
function step(t) {
|
||||||
|
const p = Math.min(1, (t - start) / dur);
|
||||||
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
const val = from + (target - from) * eased;
|
||||||
|
priceEl.innerHTML = `$${val.toFixed(2)} <small>for ${qty.toLocaleString()} ×</small>`;
|
||||||
|
if (p < 1) requestAnimationFrame(step); else shown = target;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
if (qtyEl) qtyEl.textContent = qty.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const q = parseInt(slider.value, 10);
|
||||||
|
fetch(`/api/quote/${serviceId}/${q}`)
|
||||||
|
.then(r => r.json()).then(d => render(d.retail, q))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
slider.addEventListener('input', update);
|
||||||
|
update();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Order success celebration (detect ?launched=1)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
if (new URLSearchParams(location.search).has('launched')) {
|
||||||
|
const banner = document.getElementById('launch-banner');
|
||||||
|
if (banner) {
|
||||||
|
banner.classList.add('show');
|
||||||
|
setTimeout(() => banner.classList.remove('show'), 5000);
|
||||||
|
}
|
||||||
|
celebrate();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Launch whoosh (Web Audio synth — subtle rising sweep)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
function launchSound() {
|
||||||
|
try {
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AC) return;
|
||||||
|
const ctx = new AC();
|
||||||
|
const dur = 0.55;
|
||||||
|
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
|
||||||
|
const d = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
const t = i / d.length;
|
||||||
|
d[i] = (Math.random() * 2 - 1) * Math.pow(t, 1.6) * (1 - t) * 0.9;
|
||||||
|
}
|
||||||
|
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||||
|
const filt = ctx.createBiquadFilter();
|
||||||
|
filt.type = 'bandpass'; filt.Q.value = 1.1;
|
||||||
|
filt.frequency.setValueAtTime(280, ctx.currentTime);
|
||||||
|
filt.frequency.exponentialRampToValueAtTime(2600, ctx.currentTime + dur);
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.22, ctx.currentTime + dur * 0.28);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
|
||||||
|
src.connect(filt).connect(gain).connect(ctx.destination);
|
||||||
|
src.start();
|
||||||
|
setTimeout(() => ctx.close(), dur * 1000 + 200);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
(function () {
|
||||||
|
const btn = document.querySelector('.btn.launch');
|
||||||
|
if (btn) btn.addEventListener('click', launchSound);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Orbital planet picker — drag to spin, slow auto-rotate
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const ring = document.getElementById('orbit');
|
||||||
|
if (!ring) return;
|
||||||
|
const planets = Array.from(ring.querySelectorAll('.planet-orb'));
|
||||||
|
let angle = 0, vel = 0.06, dragging = false, lastX = 0;
|
||||||
|
function layout() {
|
||||||
|
const R = 175;
|
||||||
|
planets.forEach(p => {
|
||||||
|
const i = parseFloat(p.dataset.i);
|
||||||
|
const a = ((i * 45 + angle) * Math.PI) / 180;
|
||||||
|
p.style.transform = `translate(${(Math.cos(a) * R).toFixed(1)}px, ${(Math.sin(a) * R).toFixed(1)}px)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
if (!dragging) { vel += (0.06 - vel) * 0.03; angle += vel; }
|
||||||
|
layout();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
const down = x => { dragging = true; lastX = x; ring.style.cursor = 'grabbing'; };
|
||||||
|
const move = x => { if (!dragging) return; vel = (x - lastX) * 0.18; angle += vel; lastX = x; layout(); };
|
||||||
|
const up = () => { dragging = false; ring.style.cursor = 'grab'; };
|
||||||
|
ring.style.cursor = 'grab';
|
||||||
|
ring.addEventListener('mousedown', e => down(e.clientX));
|
||||||
|
window.addEventListener('mousemove', e => move(e.clientX));
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
ring.addEventListener('touchstart', e => down(e.touches[0].clientX), { passive: true });
|
||||||
|
window.addEventListener('touchmove', e => move(e.touches[0].clientX), { passive: true });
|
||||||
|
window.addEventListener('touchend', up);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Delivery ticker (dashboard live progress, poll every 45s)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
(function () {
|
||||||
|
const wrap = document.getElementById('ticker');
|
||||||
|
if (!wrap) return;
|
||||||
|
const fmt = n => n.toLocaleString();
|
||||||
|
function bar(name, delivered, total, status) {
|
||||||
|
const pct = total > 0 ? Math.min(100, (delivered / total) * 100) : 0;
|
||||||
|
const label = status === 'completed' ? '✅ Done' : status === 'canceled' ? 'Cancelled'
|
||||||
|
: status === 'inprogress' ? '⚡ Delivering…' : '⏳ Queued';
|
||||||
|
return `<div class="tick">
|
||||||
|
<div class="tick-top"><span class="tick-name">${name}</span><span class="tick-meta">${fmt(delivered)} / ${fmt(total)} · ${label}</span></div>
|
||||||
|
<div class="tick-bar"><div class="tick-fill" style="width:${pct}%"></div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function poll() {
|
||||||
|
fetch('/api/orders-status').then(r => r.json()).then(d => {
|
||||||
|
if (!d.orders || !d.orders.length) {
|
||||||
|
wrap.innerHTML = '<div class="muted center" style="padding:18px">No boosts yet — <a href="/services" style="color:var(--nebula-b)">launch one →</a></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.innerHTML = d.orders.map(o => bar(o.name, o.delivered, o.quantity, o.status)).join('');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
setInterval(poll, 45000);
|
||||||
|
})();
|
||||||
303
static/cosmic-bg.js
Normal file
303
static/cosmic-bg.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/* cosmic-bg.js — self-contained animated backgrounds for the fleet.
|
||||||
|
* Drop in with: <script src="cosmic-bg.js" data-theme="blackhole"></script>
|
||||||
|
* Themes: starfield, cosmos, blackhole, geometric, nebula, particles, waves, grid
|
||||||
|
* Config (data attrs): data-theme, data-density (low/med/high), data-speed (0.5-2)
|
||||||
|
* All motion is slow + smooth by design. Visibility tuned to be clearly visible.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var s = document.currentScript;
|
||||||
|
var theme = (s && s.dataset.theme) || "cosmos";
|
||||||
|
var density = (s && s.dataset.density) || "med";
|
||||||
|
var speed = parseFloat((s && s.dataset.speed) || "1");
|
||||||
|
|
||||||
|
var D = density === "low" ? 0.5 : density === "high" ? 1.7 : 1;
|
||||||
|
|
||||||
|
var c = document.createElement("canvas");
|
||||||
|
c.style.cssText = "position:fixed;inset:0;z-index:0;pointer-events:none;display:block";
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
if (c.parentNode) return;
|
||||||
|
(document.body || document.documentElement).appendChild(c);
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.textContent = "body > *:not(canvas){position:relative;z-index:1}";
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
if (document.body) mount();
|
||||||
|
else document.addEventListener("DOMContentLoaded", mount);
|
||||||
|
|
||||||
|
var ctx = c.getContext("2d");
|
||||||
|
var W, H, DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
W = window.innerWidth; H = window.innerHeight;
|
||||||
|
c.width = W * DPR; c.height = H * DPR;
|
||||||
|
c.style.width = W + "px"; c.style.height = H + "px";
|
||||||
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
function rnd(a, b) { return a + Math.random() * (b - a); }
|
||||||
|
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
var PALETTES = {
|
||||||
|
cosmic: ["#a06bff", "#5b8cff", "#ff5ab5", "#e8f0ff"],
|
||||||
|
warm: ["#ff8a8a", "#ffd93d", "#ffb06a", "#fff"],
|
||||||
|
cool: ["#4fd8ff", "#6fa8ff", "#bff3f2", "#fff"],
|
||||||
|
green: ["#9fffd0", "#4de68a", "#c6ff7a", "#fff"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function hexA(hex, a) {
|
||||||
|
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return "rgba(" + r + "," + g + "," + b + "," + Math.max(0, Math.min(1, a)).toFixed(3) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
function starField(pal, drift) {
|
||||||
|
var stars = [];
|
||||||
|
var n = Math.floor(240 * D);
|
||||||
|
for (var i = 0; i < n; i++) stars.push({
|
||||||
|
x: Math.random() * W, y: Math.random() * H,
|
||||||
|
r: rnd(0.4, 2.1), tw: rnd(0, 6.28), tws: rnd(0.3, 1.2),
|
||||||
|
dr: drift * rnd(0.3, 1), c: pick(pal)
|
||||||
|
});
|
||||||
|
return function (dt, t) {
|
||||||
|
for (var j = 0; j < stars.length; j++) {
|
||||||
|
var st = stars[j];
|
||||||
|
st.y += st.dr; st.x += st.dr * 0.12;
|
||||||
|
if (st.y > H + 4) { st.y = -4; st.x = Math.random() * W; }
|
||||||
|
var a = (0.6 + 0.4 * Math.sin(t * st.tws + st.tw)) * (0.5 + 0.5 * st.r / 2.1);
|
||||||
|
ctx.globalAlpha = a; ctx.fillStyle = st.c;
|
||||||
|
ctx.beginPath(); ctx.arc(st.x, st.y, st.r, 0, 6.28); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nebulaBlobs(count, pal) {
|
||||||
|
var blobs = [];
|
||||||
|
for (var i = 0; i < count; i++) blobs.push({
|
||||||
|
x: Math.random() * W, y: Math.random() * H,
|
||||||
|
r: rnd(140, 360), dx: rnd(-0.3, 0.3), dy: rnd(-0.2, 0.2),
|
||||||
|
c: pick(pal), ph: rnd(0, 6.28)
|
||||||
|
});
|
||||||
|
return function (dt, t) {
|
||||||
|
for (var j = 0; j < blobs.length; j++) {
|
||||||
|
var b = blobs[j];
|
||||||
|
b.x += b.dx; b.y += b.dy;
|
||||||
|
if (b.x < -b.r) b.x = W + b.r; if (b.x > W + b.r) b.x = -b.r;
|
||||||
|
if (b.y < -b.r) b.y = H + b.r; if (b.y > H + b.r) b.y = -b.r;
|
||||||
|
var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
|
||||||
|
var pulse = 0.16 + 0.06 * Math.sin(t * 0.5 + b.ph);
|
||||||
|
g.addColorStop(0, hexA(b.c, pulse));
|
||||||
|
g.addColorStop(1, hexA(b.c, 0));
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes = {};
|
||||||
|
|
||||||
|
themes.starfield = function () {
|
||||||
|
var draw = starField(PALETTES.cosmic, 0.45);
|
||||||
|
return function (dt, t) { draw(dt, t); };
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.cosmos = function () {
|
||||||
|
var stars = starField(PALETTES.cosmic, 0.35);
|
||||||
|
var neb = nebulaBlobs(Math.floor(4 * D), PALETTES.cosmic);
|
||||||
|
var arms = 3, particles = [];
|
||||||
|
for (var i = 0; i < Math.floor(360 * D); i++) {
|
||||||
|
var arm = Math.floor(Math.random() * arms);
|
||||||
|
var rr = Math.pow(Math.random(), 0.6) * Math.min(W, H) * 0.44;
|
||||||
|
var aa = (arm / arms) * 6.28 + rr * 0.02 + rnd(-0.2, 0.2);
|
||||||
|
particles.push({ rr: rr, aa: aa, r: rnd(0.5, 1.8), c: pick(PALETTES.cosmic) });
|
||||||
|
}
|
||||||
|
return function (dt, t) {
|
||||||
|
neb(dt, t);
|
||||||
|
var cx = W * 0.5, cy = H * 0.5;
|
||||||
|
for (var j = 0; j < particles.length; j++) {
|
||||||
|
var p = particles[j];
|
||||||
|
p.aa += 0.0016 * speed;
|
||||||
|
var x = cx + Math.cos(p.aa) * p.rr, y = cy + Math.sin(p.aa) * p.rr;
|
||||||
|
ctx.globalAlpha = 0.75; ctx.fillStyle = p.c;
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, p.r, 0, 6.28); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
stars(dt, t);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.blackhole = function () {
|
||||||
|
var cx = W * 0.5, cy = H * 0.5;
|
||||||
|
var bhR = Math.min(W, H) * 0.14;
|
||||||
|
var acc = [];
|
||||||
|
for (var i = 0; i < Math.floor(560 * D); i++) {
|
||||||
|
var ang = rnd(0, 6.28), rad = rnd(bhR * 1.15, Math.min(W, H) * 0.5);
|
||||||
|
acc.push({ ang: ang, rad: rad, speed: 0.006 + 0.016 * (bhR / rad), r: rnd(0.6, 2.2),
|
||||||
|
c: pick(["#ffc06a", "#ff8a6a", "#ffd93d", "#ff5ab5", "#fff"]) });
|
||||||
|
}
|
||||||
|
return function (dt, t) {
|
||||||
|
// broad accretion glow
|
||||||
|
var glow = ctx.createRadialGradient(cx, cy, bhR * 0.7, cx, cy, bhR * 3.6);
|
||||||
|
glow.addColorStop(0, "rgba(255,150,60,0.5)");
|
||||||
|
glow.addColorStop(0.45, "rgba(255,45,149,0.22)");
|
||||||
|
glow.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H);
|
||||||
|
// particles spiralling in
|
||||||
|
for (var j = 0; j < acc.length; j++) {
|
||||||
|
var p = acc[j];
|
||||||
|
p.ang += p.speed * speed; p.rad -= 0.05 * speed;
|
||||||
|
if (p.rad < bhR * 1.02) { p.rad = rnd(bhR * 1.5, Math.min(W, H) * 0.5); p.ang = rnd(0, 6.28); }
|
||||||
|
var x = cx + Math.cos(p.ang) * p.rad, y = cy + Math.sin(p.ang) * p.rad;
|
||||||
|
var stretch = Math.min(1, (p.rad - bhR) / (bhR * 1.6));
|
||||||
|
ctx.globalAlpha = 0.5 + 0.5 * stretch;
|
||||||
|
ctx.fillStyle = p.c;
|
||||||
|
ctx.beginPath(); ctx.arc(x, y, p.r * (0.5 + stretch), 0, 6.28); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
// event horizon + bright photon ring
|
||||||
|
ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(cx, cy, bhR, 0, 6.28); ctx.fill();
|
||||||
|
var ring = ctx.createRadialGradient(cx, cy, bhR, cx, cy, bhR + 10);
|
||||||
|
ring.addColorStop(0, "rgba(255,220,150,0.95)"); ring.addColorStop(1, "rgba(255,220,150,0)");
|
||||||
|
ctx.strokeStyle = "rgba(255,220,150,0.95)"; ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, bhR + 1, 0, 6.28); ctx.stroke();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.geometric = function () {
|
||||||
|
var polys = [];
|
||||||
|
var n = Math.floor(22 * D);
|
||||||
|
for (var i = 0; i < n; i++) polys.push({
|
||||||
|
x: Math.random() * W, y: Math.random() * H,
|
||||||
|
sides: pick([3, 4, 6]), size: rnd(22, 60),
|
||||||
|
rot: rnd(0, 6.28), vr: rnd(-0.0025, 0.0025),
|
||||||
|
dx: rnd(-0.28, 0.28), dy: rnd(-0.22, 0.22),
|
||||||
|
c: pick(PALETTES.cosmic)
|
||||||
|
});
|
||||||
|
function poly(x, y, r, sides, rot) {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (var k = 0; k < sides; k++) {
|
||||||
|
var a = rot + (k / sides) * 6.28;
|
||||||
|
var px = x + Math.cos(a) * r, py = y + Math.sin(a) * r;
|
||||||
|
k === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
return function (dt, t) {
|
||||||
|
for (var j = 0; j < polys.length; j++) {
|
||||||
|
var p = polys[j];
|
||||||
|
p.x += p.dx; p.y += p.dy; p.rot += p.vr;
|
||||||
|
if (p.x < -70) p.x = W + 70; if (p.x > W + 70) p.x = -70;
|
||||||
|
if (p.y < -70) p.y = H + 70; if (p.y > H + 70) p.y = -70;
|
||||||
|
ctx.strokeStyle = hexA(p.c, 0.65); ctx.lineWidth = 1.4;
|
||||||
|
poly(p.x, p.y, p.size, p.sides, p.rot); ctx.stroke();
|
||||||
|
ctx.fillStyle = hexA(p.c, 0.12); ctx.fill();
|
||||||
|
}
|
||||||
|
for (var a = 0; a < polys.length; a++) {
|
||||||
|
for (var b = a + 1; b < polys.length; b++) {
|
||||||
|
var dx = polys[a].x - polys[b].x, dy = polys[a].y - polys[b].y;
|
||||||
|
var d = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (d < 170) {
|
||||||
|
ctx.strokeStyle = "rgba(190,200,255," + (0.22 * (1 - d / 170)).toFixed(3) + ")";
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath(); ctx.moveTo(polys[a].x, polys[a].y); ctx.lineTo(polys[b].x, polys[b].y); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.nebula = function () {
|
||||||
|
var neb = nebulaBlobs(Math.floor(9 * D), PALETTES.cosmic.concat(PALETTES.cool));
|
||||||
|
var stars = starField(PALETTES.cosmic, 0.3);
|
||||||
|
return function (dt, t) { neb(dt, t); stars(dt, t); };
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.particles = function () {
|
||||||
|
var ps = [];
|
||||||
|
var n = Math.floor(130 * D);
|
||||||
|
for (var i = 0; i < n; i++) ps.push({
|
||||||
|
x: Math.random() * W, y: Math.random() * H,
|
||||||
|
vx: rnd(-0.35, 0.35), vy: rnd(-0.35, 0.35), r: rnd(1.2, 3), c: pick(PALETTES.cosmic)
|
||||||
|
});
|
||||||
|
return function (dt, t) {
|
||||||
|
for (var j = 0; j < ps.length; j++) {
|
||||||
|
var p = ps[j];
|
||||||
|
p.x += p.vx; p.y += p.vy;
|
||||||
|
if (p.x < 0 || p.x > W) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > H) p.vy *= -1;
|
||||||
|
ctx.fillStyle = p.c; ctx.globalAlpha = 0.95;
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 6.28); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
for (var a = 0; a < ps.length; a++) {
|
||||||
|
for (var b = a + 1; b < ps.length; b++) {
|
||||||
|
var dx = ps[a].x - ps[b].x, dy = ps[a].y - ps[b].y;
|
||||||
|
var d = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (d < 150) {
|
||||||
|
ctx.strokeStyle = "rgba(190,200,255," + (0.25 * (1 - d / 150)).toFixed(3) + ")";
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath(); ctx.moveTo(ps[a].x, ps[a].y); ctx.lineTo(ps[b].x, ps[b].y); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.waves = function () {
|
||||||
|
var layers = [
|
||||||
|
{ amp: 46, len: 260, speed: 0.010, y: 0.65, c: "rgba(160,80,255,0.30)" },
|
||||||
|
{ amp: 60, len: 320, speed: 0.013, y: 0.72, c: "rgba(70,120,255,0.26)" },
|
||||||
|
{ amp: 76, len: 400, speed: 0.016, y: 0.80, c: "rgba(255,70,170,0.22)" },
|
||||||
|
];
|
||||||
|
return function (dt, t) {
|
||||||
|
for (var j = 0; j < layers.length; j++) {
|
||||||
|
var L = layers[j];
|
||||||
|
ctx.fillStyle = L.c; ctx.beginPath(); ctx.moveTo(0, H);
|
||||||
|
for (var x = 0; x <= W; x += 8) {
|
||||||
|
var y = H * L.y + Math.sin(x / L.len + t * L.speed * speed) * L.amp;
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
themes.grid = function () {
|
||||||
|
var horizon = H * 0.55;
|
||||||
|
return function (dt, t) {
|
||||||
|
var off = (t * 30 * speed) % 40;
|
||||||
|
ctx.strokeStyle = "rgba(150,80,255,0.45)"; ctx.lineWidth = 1.2;
|
||||||
|
for (var i = -12; i <= 12; i++) {
|
||||||
|
var x0 = W / 2 + i * 55;
|
||||||
|
ctx.beginPath(); ctx.moveTo(W / 2, horizon); ctx.lineTo(x0, H); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (var j = 0; j < 16; j++) {
|
||||||
|
var yy = horizon + Math.pow((j + off / 40) % 16 / 16, 2) * (H - horizon);
|
||||||
|
ctx.strokeStyle = "rgba(255,70,170," + (0.10 + 0.35 * ((yy - horizon) / (H - horizon))).toFixed(3) + ")";
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); ctx.stroke();
|
||||||
|
}
|
||||||
|
var g = ctx.createRadialGradient(W / 2, horizon, 0, W / 2, horizon, 170);
|
||||||
|
g.addColorStop(0, "rgba(255,70,170,0.5)"); g.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(W / 2 - 170, horizon - 170, 340, 340);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
var active = themes[theme] ? themes[theme]() : themes.cosmos();
|
||||||
|
var last = performance.now();
|
||||||
|
var raf;
|
||||||
|
function loop(now) {
|
||||||
|
var dt = (now - last) / 1000; last = now;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
active(dt, now / 1000);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", function () {
|
||||||
|
if (document.hidden) { cancelAnimationFrame(raf); }
|
||||||
|
else { last = performance.now(); raf = requestAnimationFrame(loop); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
433
static/style.css
Normal file
433
static/style.css
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
/* Supernova — galaxy theme. Slow, cinematic, ordered. */
|
||||||
|
:root {
|
||||||
|
--bg: #050510;
|
||||||
|
--bg2: #0a0a22;
|
||||||
|
--panel: rgba(20, 18, 48, 0.55);
|
||||||
|
--panel-border: rgba(130, 110, 255, 0.18);
|
||||||
|
--nebula-a: #7b2ff7;
|
||||||
|
--nebula-b: #2b6cff;
|
||||||
|
--nebula-c: #ff2d95;
|
||||||
|
--star: #cfe0ff;
|
||||||
|
--text: #e8eaf6;
|
||||||
|
--muted: #9aa0c0;
|
||||||
|
--glow: 0 0 24px rgba(123, 47, 247, 0.35);
|
||||||
|
--radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
|
||||||
|
min-height: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { overflow-x: hidden; }
|
||||||
|
|
||||||
|
/* ------- background: starfield canvas + nebula ------- */
|
||||||
|
#starfield {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 50% at 15% 20%, rgba(123,47,247,0.28), transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 45% at 85% 30%, rgba(43,108,255,0.22), transparent 60%),
|
||||||
|
radial-gradient(ellipse 55% 50% at 50% 90%, rgba(255,45,149,0.18), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, var(--bg2) 60%, #0a0618 100%);
|
||||||
|
animation: nebula-drift 40s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nebula-drift {
|
||||||
|
0% { transform: translate(0,0) scale(1); }
|
||||||
|
100% { transform: translate(0,-2%) scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- layout ------- */
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- nav ------- */
|
||||||
|
nav.top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
background: rgba(8, 8, 24, 0.6);
|
||||||
|
border-bottom: 1px solid var(--panel-border);
|
||||||
|
}
|
||||||
|
nav.top .inner {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.brand .orb {
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #e6d0ff, var(--nebula-a) 60%, var(--nebula-c));
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
.navlinks { margin-left: auto; display: flex; align-items: center; gap: 18px; }
|
||||||
|
.navlinks a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color .2s;
|
||||||
|
}
|
||||||
|
.navlinks a:hover { color: var(--text); }
|
||||||
|
.balance-pill {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
padding: 6px 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7cffb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- buttons ------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
padding: 12px 26px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--nebula-a), var(--nebula-b));
|
||||||
|
box-shadow: 0 4px 24px rgba(123,47,247,0.4);
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-2px); box-shadow: 0 8px 34px rgba(123,47,247,0.55); }
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn.ghost:hover { background: rgba(123,47,247,0.12); }
|
||||||
|
.btn.sm { padding: 8px 16px; font-size: 0.86rem; }
|
||||||
|
|
||||||
|
/* ------- hero ------- */
|
||||||
|
.hero {
|
||||||
|
padding: 88px 0 72px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2.4rem, 6vw, 4.4rem);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
line-height: 1.05;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
background: linear-gradient(120deg, #ffffff, #cfc4ff 40%, var(--nebula-c));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.hero p.sub {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.hero .cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ------- stat strip ------- */
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 48px 0;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 22px;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.stat .n { font-size: 1.9rem; font-weight: 900; color: #fff; }
|
||||||
|
.stat .l { color: var(--muted); font-size: 0.88rem; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ------- platform grid (services) ------- */
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 40px 0 18px;
|
||||||
|
}
|
||||||
|
.platform-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: transform .22s ease, box-shadow .22s ease, border-color .22s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: rgba(130,110,255,0.45);
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
.card h3 { margin: 0 0 8px; font-size: 1.15rem; }
|
||||||
|
.card .meta { color: var(--muted); font-size: 0.86rem; }
|
||||||
|
.card .price {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #7cffb0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; }
|
||||||
|
|
||||||
|
/* platform icon "planets" */
|
||||||
|
.planet {
|
||||||
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
box-shadow: inset -4px -4px 10px rgba(0,0,0,0.35);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- forms ------- */
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label { display: block; color: var(--muted); font-size: 0.86rem; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(10,10,30,0.6);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: var(--nebula-b); box-shadow: 0 0 0 3px rgba(43,108,255,0.2); }
|
||||||
|
|
||||||
|
/* ------- slider ------- */
|
||||||
|
.slider-wrap { margin: 22px 0; }
|
||||||
|
input[type=range] {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 100%; height: 8px; border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--nebula-a), var(--nebula-b));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 4px solid var(--nebula-c);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
.live-price {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #7cffb0;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.live-price small { color: var(--muted); font-size: 0.85rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ------- tables ------- */
|
||||||
|
table.list { width: 100%; border-collapse: collapse; }
|
||||||
|
table.list th, table.list td {
|
||||||
|
text-align: left; padding: 13px 14px;
|
||||||
|
border-bottom: 1px solid var(--panel-border);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
table.list th { color: var(--muted); font-weight: 700; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 3px 11px; border-radius: 999px;
|
||||||
|
font-size: 0.78rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge.pending { background: rgba(255,200,80,0.18); color: #ffd36b; }
|
||||||
|
.badge.completed{ background: rgba(124,255,176,0.18); color: #7cffb0; }
|
||||||
|
.badge.credited { background: rgba(124,255,176,0.18); color: #7cffb0; }
|
||||||
|
.badge.inprogress{ background: rgba(90,160,255,0.18); color: #7cc4ff; }
|
||||||
|
.badge.canceled { background: rgba(255,120,120,0.18); color: #ff9a9a; }
|
||||||
|
|
||||||
|
/* ------- flash ------- */
|
||||||
|
.flash {
|
||||||
|
padding: 13px 18px; border-radius: 11px; margin: 16px 0;
|
||||||
|
font-weight: 600; font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
.flash.error { background: rgba(255,90,90,0.16); border: 1px solid rgba(255,90,90,0.35); color: #ffb3b3; }
|
||||||
|
.flash.success { background: rgba(124,255,176,0.14); border: 1px solid rgba(124,255,176,0.35); color: #a4ffc9; }
|
||||||
|
|
||||||
|
/* ------- auth card ------- */
|
||||||
|
.auth-card { max-width: 420px; margin: 60px auto; }
|
||||||
|
|
||||||
|
/* ------- footer ------- */
|
||||||
|
footer.site {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
margin-top: 80px;
|
||||||
|
padding: 34px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--panel-border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
footer.site a { color: var(--nebula-b); text-decoration: none; font-weight: 600; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.mt-2 { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* ------- interactivity: press, tilt, reveal, ripple, launch ------- */
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px) scale(0.97);
|
||||||
|
box-shadow: 0 2px 12px rgba(123,47,247,0.4);
|
||||||
|
}
|
||||||
|
@keyframes ripple {
|
||||||
|
from { transform: scale(0); opacity: 0.5; }
|
||||||
|
to { transform: scale(2.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card tilt smoothing — gentle, cinematic */
|
||||||
|
.card {
|
||||||
|
transition: transform .25s cubic-bezier(.22,.68,.43,1), box-shadow .22s ease, border-color .22s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.card.reveal { transition: opacity .7s ease, transform .7s ease, box-shadow .22s ease, border-color .22s ease; }
|
||||||
|
|
||||||
|
/* scroll reveal */
|
||||||
|
.reveal { opacity: 0; transform: translateY(16px); }
|
||||||
|
.reveal.revealed { opacity: 1; transform: translateY(0); }
|
||||||
|
|
||||||
|
/* launch button — pulsing "ready to go" glow */
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 4px 24px rgba(123,47,247,0.4); }
|
||||||
|
50% { box-shadow: 0 4px 44px rgba(255,45,149,0.6); }
|
||||||
|
}
|
||||||
|
.btn.launch { animation: pulse-glow 2.6s ease-in-out infinite; }
|
||||||
|
.btn.launch .rocket { display: inline-block; transition: transform .2s ease; }
|
||||||
|
.btn.launch:hover .rocket { transform: translateY(-3px) translateX(2px); }
|
||||||
|
.btn.launch:active .rocket { transform: translateY(6px); }
|
||||||
|
|
||||||
|
/* launch celebration banner */
|
||||||
|
#launch-banner {
|
||||||
|
position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-80px);
|
||||||
|
z-index: 10000;
|
||||||
|
background: linear-gradient(135deg, var(--nebula-a), var(--nebula-c));
|
||||||
|
color: #fff; font-weight: 800; padding: 14px 28px; border-radius: 999px;
|
||||||
|
box-shadow: 0 8px 40px rgba(255,45,149,0.5);
|
||||||
|
opacity: 0; pointer-events: none;
|
||||||
|
transition: transform .5s cubic-bezier(.22,1,.36,1), opacity .4s ease;
|
||||||
|
}
|
||||||
|
#launch-banner.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
|
||||||
|
/* ------- orbital planet picker ------- */
|
||||||
|
.orbit-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
height: 440px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.orbit-sun {
|
||||||
|
width: 92px; height: 92px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #fff, var(--nebula-a) 50%, var(--nebula-c));
|
||||||
|
box-shadow: 0 0 50px rgba(123,47,247,0.7), 0 0 90px rgba(255,45,149,0.4);
|
||||||
|
z-index: 2;
|
||||||
|
animation: sun-pulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sun-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 40px rgba(123,47,247,0.6), 0 0 80px rgba(255,45,149,0.35); }
|
||||||
|
50% { box-shadow: 0 0 62px rgba(123,47,247,0.85), 0 0 115px rgba(255,45,149,0.55); }
|
||||||
|
}
|
||||||
|
.orbit-ring { position: absolute; inset: 0; }
|
||||||
|
.orbit-ring::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; top: 50%; left: 50%;
|
||||||
|
width: 350px; height: 350px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(130,110,255,0.16);
|
||||||
|
}
|
||||||
|
.planet-orb {
|
||||||
|
position: absolute; top: 50%; left: 50%;
|
||||||
|
width: 64px; height: 64px;
|
||||||
|
margin: -32px 0 0 -32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #3b2b8a, #150d2c 72%);
|
||||||
|
box-shadow: inset -4px -4px 10px rgba(0,0,0,0.45), 0 0 18px rgba(123,47,247,0.35);
|
||||||
|
transition: box-shadow .3s ease, background .3s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.planet-orb .po-emoji { font-size: 1.5rem; pointer-events: none; }
|
||||||
|
.planet-orb .po-name {
|
||||||
|
position: absolute; top: 100%; left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(6px);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.75rem; font-weight: 700; color: var(--muted);
|
||||||
|
opacity: 0; transition: opacity .25s ease, color .25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.planet-orb:hover { box-shadow: inset -4px -4px 10px rgba(0,0,0,0.45), 0 0 30px rgba(255,45,149,0.65); z-index: 3; }
|
||||||
|
.planet-orb:hover .po-name { opacity: 1; color: var(--text); }
|
||||||
|
|
||||||
|
/* ------- delivery ticker ------- */
|
||||||
|
.tick { margin-bottom: 16px; }
|
||||||
|
.tick-top { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 6px; }
|
||||||
|
.tick-name { font-weight: 700; font-size: 0.92rem; }
|
||||||
|
.tick-meta { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
.tick-bar {
|
||||||
|
height: 10px; border-radius: 999px;
|
||||||
|
background: rgba(10,10,30,0.6);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tick-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--nebula-a), var(--nebula-c));
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 1s cubic-bezier(.22,1,.36,1);
|
||||||
|
}
|
||||||
|
|
||||||
12
supernova.nginx
Normal file
12
supernova.nginx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name supernova.thetempleofdoom.com 10.30.20.203;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
supernova.service
Normal file
13
supernova.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Supernova SMM Reseller Panel
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/opt/supernova
|
||||||
|
EnvironmentFile=/opt/supernova/.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/supernova/app.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
28
templates/auth.html
Normal file
28
templates/auth.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ "Sign up" if mode == "signup" else "Sign in" }} — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1.4rem">{{ "Create your account" if mode == "signup" else "Welcome back" }}</h3>
|
||||||
|
<div class="meta">Pay in Bitcoin. Boost any platform.</div>
|
||||||
|
<form method="POST" style="margin-top:20px">
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" required minlength="8" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" style="width:100%">{{ "Sign up" if mode == "signup" else "Sign in" }}</button>
|
||||||
|
</form>
|
||||||
|
<div class="meta center mt-2">
|
||||||
|
{% if mode == "signup" %}
|
||||||
|
Already have an account? <a href="{{ url_for('login') }}" style="color:var(--nebula-b)">Sign in</a>
|
||||||
|
{% else %}
|
||||||
|
New here? <a href="{{ url_for('signup') }}" style="color:var(--nebula-b)">Create account</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
97
templates/base.html
Normal file
97
templates/base.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Supernova — Buy Instagram, TikTok & YouTube Followers Cheap | Bitcoin SMM Panel{% endblock %}</title>
|
||||||
|
<meta name="description" content="{% block description %}Buy cheap Instagram, TikTok, YouTube, Facebook & X followers, likes and views from $0.02 per 1k. Pay with Bitcoin, instant automated delivery, 361 services across 18+ platforms.{% endblock %}">
|
||||||
|
<meta name="keywords" content="buy instagram followers, buy tiktok followers, buy youtube views, buy followers cheap, smm panel, social media marketing panel, buy likes, buy views, buy facebook followers, buy x twitter followers, bitcoin smm panel">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://supernova.thetempleofdoom.com/">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="Supernova">
|
||||||
|
<meta property="og:title" content="Supernova — Buy Instagram, TikTok & YouTube Followers Cheap | Bitcoin SMM Panel">
|
||||||
|
<meta property="og:description" content="Buy cheap followers, likes and views for Instagram, TikTok, YouTube, Facebook & X. Pay with Bitcoin. Instant automated delivery. 361 services, 18+ platforms.">
|
||||||
|
<meta property="og:url" content="https://supernova.thetempleofdoom.com/">
|
||||||
|
<meta property="og:image" content="https://supernova.thetempleofdoom.com/static/og.png">
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Supernova — Buy Instagram, TikTok & YouTube Followers Cheap">
|
||||||
|
<meta name="twitter:description" content="Buy cheap followers, likes & views. Pay with Bitcoin. Instant automated delivery across 18+ platforms.">
|
||||||
|
<meta name="twitter:image" content="https://supernova.thetempleofdoom.com/static/og.png">
|
||||||
|
|
||||||
|
<!-- JSON-LD -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Supernova",
|
||||||
|
"url": "https://supernova.thetempleofdoom.com/",
|
||||||
|
"description": "Bitcoin-powered social media growth panel. Buy Instagram, TikTok, YouTube, Facebook and X followers, likes and views.",
|
||||||
|
"logo": "https://supernova.thetempleofdoom.com/static/og.png"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Supernova",
|
||||||
|
"url": "https://supernova.thetempleofdoom.com/",
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": "https://supernova.thetempleofdoom.com/services?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=3">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||||
|
|
||||||
|
<!-- Umami analytics -->
|
||||||
|
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="953c15df-ba4c-453a-a7c6-465fa9e3f202"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="starfield"></canvas>
|
||||||
|
<div class="nebula"></div>
|
||||||
|
|
||||||
|
<nav class="top">
|
||||||
|
<div class="inner">
|
||||||
|
<a class="brand" href="{{ url_for('index') }}"><span class="orb"></span>Supernova</a>
|
||||||
|
<div class="navlinks">
|
||||||
|
<a href="{{ url_for('services') }}">Services</a>
|
||||||
|
{% if user %}
|
||||||
|
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
||||||
|
<a href="{{ url_for('orders') }}">Orders</a>
|
||||||
|
<span class="balance-pill">₿ ${{ "%.2f"|format(user["balance"]) }}</span>
|
||||||
|
<a class="btn sm" href="{{ url_for('deposit') }}">+ Add funds</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}">Sign in</a>
|
||||||
|
<a class="btn sm" href="{{ url_for('signup') }}">Get started</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, m in msgs %}
|
||||||
|
<div class="flash {{ cat }}">{{ m }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site">
|
||||||
|
<div class="wrap">
|
||||||
|
Powered by Supernova · <a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">☕ Support this project</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}?v=3"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
templates/bg-demo.html
Normal file
72
templates/bg-demo.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Cosmic Backgrounds — theme gallery</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #e8eaf6; background: #050510; }
|
||||||
|
.overlay {
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
max-width: 900px; margin: 0 auto; padding: 60px 24px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2rem; margin: 0 0 6px; }
|
||||||
|
.sub { color: #9aa0c0; margin: 0 0 28px; }
|
||||||
|
.picker { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 40px; }
|
||||||
|
.picker a {
|
||||||
|
text-decoration: none; color: #cfc4ff; font-weight: 600; font-size: 0.92rem;
|
||||||
|
padding: 10px 18px; border-radius: 999px;
|
||||||
|
border: 1px solid rgba(130,110,255,0.3);
|
||||||
|
background: rgba(20,18,48,0.55);
|
||||||
|
transition: all .2s ease;
|
||||||
|
}
|
||||||
|
.picker a.active, .picker a:hover {
|
||||||
|
background: linear-gradient(135deg, #7b2ff7, #2b6cff);
|
||||||
|
color: #fff; border-color: transparent;
|
||||||
|
box-shadow: 0 4px 20px rgba(123,47,247,0.4);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(20,18,48,0.6);
|
||||||
|
border: 1px solid rgba(130,110,255,0.2);
|
||||||
|
border-radius: 18px; padding: 28px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 10px; font-size: 1.2rem; }
|
||||||
|
.card p { color: #9aa0c0; line-height: 1.7; margin: 0; }
|
||||||
|
code {
|
||||||
|
display: block; margin-top: 40px; padding: 14px 18px;
|
||||||
|
background: rgba(10,10,30,0.7); border: 1px solid rgba(130,110,255,0.2);
|
||||||
|
border-radius: 10px; font-size: 0.85rem; color: #7cffb0;
|
||||||
|
overflow-x: auto; white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="{{ url_for('static', filename='cosmic-bg.js') }}" data-theme="{{ theme }}"></script>
|
||||||
|
|
||||||
|
<div class="overlay">
|
||||||
|
<h1>✨ Cosmic Backgrounds</h1>
|
||||||
|
<p class="sub">Current theme: <strong>{{ theme }}</strong> — click any to preview. Drop into any site with one script tag.</p>
|
||||||
|
|
||||||
|
<div class="picker">
|
||||||
|
{% for t in themes %}
|
||||||
|
<a href="{{ url_for('bg_demo') }}?theme={{ t }}" class="{{ 'active' if t == theme else '' }}">{{ t }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>This is how your content sits over it</h2>
|
||||||
|
<p>The background is a fixed, pointer-events-none canvas layered behind everything — your existing site, nav, cards and text all render on top unchanged. Every theme is slow + smooth, and pauses automatically when the tab is in the background (zero wasted CPU).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Add it to any site</h2>
|
||||||
|
<p>One line in the <code><head></code> — swap <code>data-theme</code> to taste:</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code><script src="https://supernova.thetempleofdoom.com/static/cosmic-bg.js" data-theme="{{ theme }}"></script></code>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
templates/dashboard.html
Normal file
43
templates/dashboard.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="section-title" style="margin-top:36px">Your command deck</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="n">₿ ${{ "%.2f"|format(user.balance) }}</div><div class="l">balance</div></div>
|
||||||
|
<div class="stat"><div class="n">{{ orders|length }}</div><div class="l">recent orders</div></div>
|
||||||
|
<div class="stat"><div class="n">⚡</div><div class="l">auto-fulfilled</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="platform-grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Add funds</h3>
|
||||||
|
<div class="meta">Deposit Bitcoin — credited instantly on confirmation.</div>
|
||||||
|
<div class="row"><a class="btn sm" href="{{ url_for('deposit') }}">Deposit ₿</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>New boost</h3>
|
||||||
|
<div class="meta">Browse the catalog and launch a campaign.</div>
|
||||||
|
<div class="row"><a class="btn sm" href="{{ url_for('services') }}">Explore →</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Order history</h3>
|
||||||
|
<div class="meta">Track every boost and its status.</div>
|
||||||
|
<div class="row"><a class="btn sm ghost" href="{{ url_for('orders') }}">View all</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Live delivery</div>
|
||||||
|
<div class="card" style="padding:22px">
|
||||||
|
<div id="ticker"><div class="muted center">Loading your boosts…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if deposits %}
|
||||||
|
<div class="section-title">Recent deposits</div>
|
||||||
|
<table class="list">
|
||||||
|
<tr><th>Amount</th><th>Status</th></tr>
|
||||||
|
{% for d in deposits %}
|
||||||
|
<tr><td>${{ "%.2f"|format(d.amount) }}</td><td><span class="badge {{ d.status }}">{{ d.status }}</span></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
18
templates/deposit.html
Normal file
18
templates/deposit.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Deposit — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1.4rem">Add Bitcoin funds</h3>
|
||||||
|
<div class="meta">Current balance: <strong style="color:#7cffb0">${{ "%.2f"|format(user.balance) }}</strong>. Deposits credit automatically once the BTC confirms.</div>
|
||||||
|
<form method="POST" style="margin-top:20px">
|
||||||
|
<div class="field">
|
||||||
|
<label>Amount (USD)</label>
|
||||||
|
<input type="number" name="amount" step="0.01" min="0.50" required placeholder="25.00">
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" style="width:100%">⚡ Generate ₿ invoice</button>
|
||||||
|
</form>
|
||||||
|
<div class="meta center mt-2">You'll be redirected to a Bitcoin checkout page.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
69
templates/index.html
Normal file
69
templates/index.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Supernova — Buy Instagram, TikTok & YouTube Followers Cheap | Bitcoin SMM Panel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Grow your reach<br>across the galaxy</h1>
|
||||||
|
<p class="sub">Followers, likes and views for every platform — paid in Bitcoin, delivered to your handle. Pick your world, choose your boost, watch it rise.</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a class="btn" href="{{ url_for('services') }}">🌌 Explore services</a>
|
||||||
|
{% if not user %}<a class="btn ghost" href="{{ url_for('signup') }}">Create account</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="n">361</div><div class="l">services</div></div>
|
||||||
|
<div class="stat"><div class="n">18+</div><div class="l">platforms</div></div>
|
||||||
|
<div class="stat"><div class="n">₿</div><div class="l">Bitcoin payments</div></div>
|
||||||
|
<div class="stat"><div class="n">100%</div><div class="l">automated</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Spin the galaxy — choose your world</div>
|
||||||
|
<div class="orbit-wrap">
|
||||||
|
<div class="orbit-sun">🌌</div>
|
||||||
|
<div class="orbit-ring" id="orbit">
|
||||||
|
{% for name, icon in [("Instagram","📸"),("TikTok","🎵"),("YouTube","▶️"),("X / Twitter","🐦"),("Facebook","👥"),("Spotify","🎧"),("Telegram","✈️"),("Snapchat","👻")] %}
|
||||||
|
<a class="planet-orb" href="{{ url_for('services') }}" data-i="{{ loop.index0 }}" data-name="{{ name }}">
|
||||||
|
<span class="po-emoji">{{ icon }}</span>
|
||||||
|
<span class="po-name">{{ name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="center muted" style="margin-top:10px">← drag to spin · click a planet to explore →</div>
|
||||||
|
|
||||||
|
<section class="seo-block">
|
||||||
|
<h2>Buy followers, likes & views for every social platform</h2>
|
||||||
|
<p>Supernova is a <strong>Bitcoin-powered social media marketing panel</strong> — the fastest way to grow your presence on <a href="{{ url_for('services') }}">Instagram</a>, TikTok, YouTube, Facebook, X (Twitter), Spotify, Telegram and more. With <strong>361 services</strong> starting from just <strong>$0.02 per 1,000</strong>, you can buy exactly the engagement you need and watch it land automatically.</p>
|
||||||
|
|
||||||
|
<h3>Instagram</h3>
|
||||||
|
<p>Buy Instagram followers, likes, views, reel views and comments. From lifetime likes to real low-drop followers and country-targeted growth — boost your profile and look established overnight.</p>
|
||||||
|
|
||||||
|
<h3>TikTok</h3>
|
||||||
|
<p>Buy TikTok followers, likes, views, shares and saves. Kickstart the algorithm and push your videos into the For You feed with fast, drip-fed delivery.</p>
|
||||||
|
|
||||||
|
<h3>YouTube</h3>
|
||||||
|
<p>Buy YouTube views, subscribers, likes and watch-time. Grow your channel's authority and signal relevance to YouTube's recommendation engine.</p>
|
||||||
|
|
||||||
|
<h3>X (Twitter) & Facebook</h3>
|
||||||
|
<p>Buy X followers, retweets, likes and impressions — plus Facebook page likes, followers, comments and live-stream viewers.</p>
|
||||||
|
|
||||||
|
<h3>Why Supernova</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Pay with Bitcoin</strong> — private, no KYC, instant credit.</li>
|
||||||
|
<li><strong>Fully automated</strong> — order and delivery starts in minutes.</li>
|
||||||
|
<li><strong>Cheap & scalable</strong> — from a few hundred to millions of units.</li>
|
||||||
|
<li><strong>Refill & drip-feed</strong> — services with lifetime refill and gradual delivery.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="cta-line"><a class="btn" href="{{ url_for('services') }}">Browse all 361 services →</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seo-block { margin-top: 64px; padding: 8px 0 24px; }
|
||||||
|
.seo-block h2 { font-size: 1.6rem; font-weight: 800; }
|
||||||
|
.seo-block h3 { font-size: 1.15rem; margin-top: 26px; }
|
||||||
|
.seo-block p { color: var(--muted); line-height: 1.7; }
|
||||||
|
.seo-block a { color: var(--nebula-b); }
|
||||||
|
.seo-block ul { color: var(--muted); line-height: 1.8; }
|
||||||
|
.seo-block .cta-line { margin-top: 28px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
29
templates/order.html
Normal file
29
templates/order.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Order {{ s.name }} — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-card" style="max-width:560px">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1.4rem;margin-bottom:6px">{{ s.name }}</h3>
|
||||||
|
<div class="meta">Category: {{ s.category }} · rate ${{ "%.2f"|format(s.rate|float) }}/1k · min {{ s.min }} · max {{ "{:,}".format(s.max) }}</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('order', service_id=s.service) }}">
|
||||||
|
<div class="field" style="margin-top:22px">
|
||||||
|
<label>Target link / username</label>
|
||||||
|
<input type="text" name="link" placeholder="https://instagram.com/yourhandle" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-wrap">
|
||||||
|
<label>Quantity</label>
|
||||||
|
<div class="live-price" id="live-price">$0.00</div>
|
||||||
|
<input type="range" id="qty" name="quantity" data-service="{{ s.service }}"
|
||||||
|
min="{{ s.min }}" max="{{ [s.max, 50000]|min }}" step="{{ [1, s.min//10]|max }}">
|
||||||
|
<div class="meta center" style="margin-top:10px"><span id="qty-val">{{ s.min }}</span> units</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn launch" type="submit" style="width:100%;margin-top:14px"><span class="rocket">🚀</span> Launch boost</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="meta center mt-2">Charged from your balance. {% if s.refill %}♻️ Auto-refill included.{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
templates/orders.html
Normal file
25
templates/orders.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Orders — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div id="launch-banner">🚀 Boost launched — delivery starting!</div>
|
||||||
|
<div class="section-title" style="margin-top:36px">Your boosts</div>
|
||||||
|
{% if orders %}
|
||||||
|
<table class="list">
|
||||||
|
<tr><th>Service</th><th>Link</th><th>Qty</th><th>Cost</th><th>Status</th></tr>
|
||||||
|
{% for o in orders %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.service_name }}</td>
|
||||||
|
<td class="muted" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ o.link }}</td>
|
||||||
|
<td>{{ "{:,}".format(o.quantity) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(o.cost) }}</td>
|
||||||
|
<td><span class="badge {{ o.status }}">{{ o.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="card center" style="padding:40px">
|
||||||
|
<div style="font-size:2rem">🪐</div>
|
||||||
|
<div class="muted mt-2">No orders yet. <a href="{{ url_for('services') }}" style="color:var(--nebula-b)">Launch your first boost →</a></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
57
templates/services.html
Normal file
57
templates/services.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Services — Supernova{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="hero" style="padding:48px 0 24px">
|
||||||
|
<h1 style="font-size:2.4rem">Services</h1>
|
||||||
|
<p class="sub">Search the catalog. Every boost is fulfilled automatically.</p>
|
||||||
|
<div class="field" style="max-width:520px;margin:0 auto">
|
||||||
|
<input id="search" type="text" placeholder="Search — e.g. Instagram likes, TikTok views…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="catalog">
|
||||||
|
{% for cat, items in cats.items() %}
|
||||||
|
<section class="cat-block" data-cat="{{ cat|lower }}">
|
||||||
|
<h2 class="section-title">{{ cat }} <span class="muted" style="font-size:0.9rem">({{ items|length }})</span></h2>
|
||||||
|
<div class="platform-grid">
|
||||||
|
{% for s in items %}
|
||||||
|
<div class="card" data-name="{{ s.name|lower }}">
|
||||||
|
<h3>{{ s.name }}</h3>
|
||||||
|
<div class="meta">min {{ s.min }} · max {{ "{:,}".format(s.max) }}</div>
|
||||||
|
<div class="price">${{ "%.2f"|format(s.rate|float) }} <span class="muted" style="font-size:0.8rem">/ 1k</span></div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="meta">{% if s.refill %}♻️ refill{% endif %}{% if s.dripfeed %} 💧 dripfeed{% endif %}{% if not s.refill and not s.dripfeed %}·{% endif %}</span>
|
||||||
|
<a class="btn sm" href="{{ url_for('order', service_id=s.service) }}">Boost →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-results" class="center muted" style="display:none;padding:40px 0">No services match your search.</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const search = document.getElementById('search');
|
||||||
|
const cards = document.querySelectorAll('.card[data-name]');
|
||||||
|
const blocks = document.querySelectorAll('.cat-block');
|
||||||
|
const noRes = document.getElementById('no-results');
|
||||||
|
search.addEventListener('input', () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
let any = false;
|
||||||
|
cards.forEach(c => {
|
||||||
|
const hit = !q || c.dataset.name.includes(q);
|
||||||
|
c.style.display = hit ? '' : 'none';
|
||||||
|
if (hit) any = true;
|
||||||
|
});
|
||||||
|
blocks.forEach(b => {
|
||||||
|
const has = Array.from(b.querySelectorAll('.card[data-name]')).some(c => c.style.display !== 'none');
|
||||||
|
b.style.display = has ? '' : 'none';
|
||||||
|
});
|
||||||
|
noRes.style.display = any ? 'none' : '';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user