From ed212cdbd9eb820e7a15dec8db8d6f3c633133e1 Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 6 Oct 2026 23:43:23 -0700 Subject: [PATCH] Snapshot: full project state --- .gitignore | 17 ++ README.md | 17 ++ app.py | 454 +++++++++++++++++++++++++++++++++++++++ credit.sh | 2 + deploy.sh | 38 ++++ link-wallet.sh | 16 ++ panel_client.py | 32 +++ static/app.js | 315 +++++++++++++++++++++++++++ static/cosmic-bg.js | 303 ++++++++++++++++++++++++++ static/style.css | 433 +++++++++++++++++++++++++++++++++++++ supernova.nginx | 12 ++ supernova.service | 13 ++ templates/auth.html | 28 +++ templates/base.html | 97 +++++++++ templates/bg-demo.html | 72 +++++++ templates/dashboard.html | 43 ++++ templates/deposit.html | 18 ++ templates/index.html | 69 ++++++ templates/order.html | 29 +++ templates/orders.html | 25 +++ templates/services.html | 57 +++++ 21 files changed, 2090 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 credit.sh create mode 100644 deploy.sh create mode 100644 link-wallet.sh create mode 100644 panel_client.py create mode 100644 static/app.js create mode 100644 static/cosmic-bg.js create mode 100644 static/style.css create mode 100644 supernova.nginx create mode 100644 supernova.service create mode 100644 templates/auth.html create mode 100644 templates/base.html create mode 100644 templates/bg-demo.html create mode 100644 templates/dashboard.html create mode 100644 templates/deposit.html create mode 100644 templates/index.html create mode 100644 templates/order.html create mode 100644 templates/orders.html create mode 100644 templates/services.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b2937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.pyc +node_modules/ +.venv/ +venv/ +.env +*.db +*.sqlite* +*.log +.DS_Store +out/ +work/ +.pio/ +briefs/ +dns-backup/ +archive/ +*.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb2f59e --- /dev/null +++ b/README.md @@ -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 + diff --git a/app.py b/app.py new file mode 100644 index 0000000..a39ce47 --- /dev/null +++ b/app.py @@ -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"https://supernova.thetempleofdoom.com{p}" + f"{freq}{pri}" + for p, pri, freq in pages + ) + xml = ('\n' + '' + f"{urls}") + 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/", 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//") +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) diff --git a/credit.sh b/credit.sh new file mode 100644 index 0000000..2a0ae46 --- /dev/null +++ b/credit.sh @@ -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';" diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..04e971a --- /dev/null +++ b/deploy.sh @@ -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 ===" diff --git a/link-wallet.sh b/link-wallet.sh new file mode 100644 index 0000000..3c91bb2 --- /dev/null +++ b/link-wallet.sh @@ -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'" diff --git a/panel_client.py b/panel_client.py new file mode 100644 index 0000000..616f44b --- /dev/null +++ b/panel_client.py @@ -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) diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e1a3c7a --- /dev/null +++ b/static/app.js @@ -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)} for ${qty.toLocaleString()} ×`; + 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 `
+
${name}${fmt(delivered)} / ${fmt(total)} · ${label}
+
+
`; + } + function poll() { + fetch('/api/orders-status').then(r => r.json()).then(d => { + if (!d.orders || !d.orders.length) { + wrap.innerHTML = '
No boosts yet — launch one →
'; + return; + } + wrap.innerHTML = d.orders.map(o => bar(o.name, o.delivered, o.quantity, o.status)).join(''); + }).catch(() => {}); + } + poll(); + setInterval(poll, 45000); +})(); diff --git a/static/cosmic-bg.js b/static/cosmic-bg.js new file mode 100644 index 0000000..644195e --- /dev/null +++ b/static/cosmic-bg.js @@ -0,0 +1,303 @@ +/* cosmic-bg.js — self-contained animated backgrounds for the fleet. + * Drop in with: + * 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); } + }); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3376173 --- /dev/null +++ b/static/style.css @@ -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); +} + diff --git a/supernova.nginx b/supernova.nginx new file mode 100644 index 0000000..03cf8a6 --- /dev/null +++ b/supernova.nginx @@ -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; + } +} diff --git a/supernova.service b/supernova.service new file mode 100644 index 0000000..cc9b875 --- /dev/null +++ b/supernova.service @@ -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 diff --git a/templates/auth.html b/templates/auth.html new file mode 100644 index 0000000..d520bb9 --- /dev/null +++ b/templates/auth.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ "Sign up" if mode == "signup" else "Sign in" }} — Supernova{% endblock %} +{% block content %} +
+
+

{{ "Create your account" if mode == "signup" else "Welcome back" }}

+
Pay in Bitcoin. Boost any platform.
+
+
+ + +
+
+ + +
+ +
+
+ {% if mode == "signup" %} + Already have an account? Sign in + {% else %} + New here? Create account + {% endif %} +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1389af7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,97 @@ + + + + + + {% block title %}Supernova — Buy Instagram, TikTok & YouTube Followers Cheap | Bitcoin SMM Panel{% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %} +
{{ m }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + + + + + diff --git a/templates/bg-demo.html b/templates/bg-demo.html new file mode 100644 index 0000000..c15651b --- /dev/null +++ b/templates/bg-demo.html @@ -0,0 +1,72 @@ + + + + + + Cosmic Backgrounds — theme gallery + + + + + +
+

✨ Cosmic Backgrounds

+

Current theme: {{ theme }} — click any to preview. Drop into any site with one script tag.

+ +
+ {% for t in themes %} + {{ t }} + {% endfor %} +
+ +
+

This is how your content sits over it

+

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).

+
+ +
+

Add it to any site

+

One line in the <head> — swap data-theme to taste:

+
+ + <script src="https://supernova.thetempleofdoom.com/static/cosmic-bg.js" data-theme="{{ theme }}"></script> +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..a703da9 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Supernova{% endblock %} +{% block content %} +
Your command deck
+
+
₿ ${{ "%.2f"|format(user.balance) }}
balance
+
{{ orders|length }}
recent orders
+
⚡
auto-fulfilled
+
+ +
+
+

Add funds

+
Deposit Bitcoin — credited instantly on confirmation.
+ +
+
+

New boost

+
Browse the catalog and launch a campaign.
+ +
+
+

Order history

+
Track every boost and its status.
+ +
+
+ +
Live delivery
+
+
Loading your boosts…
+
+ +{% if deposits %} +
Recent deposits
+ + + {% for d in deposits %} + + {% endfor %} +
AmountStatus
${{ "%.2f"|format(d.amount) }}{{ d.status }}
+{% endif %} +{% endblock %} diff --git a/templates/deposit.html b/templates/deposit.html new file mode 100644 index 0000000..7eb7e58 --- /dev/null +++ b/templates/deposit.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Deposit — Supernova{% endblock %} +{% block content %} +
+
+

Add Bitcoin funds

+
Current balance: ${{ "%.2f"|format(user.balance) }}. Deposits credit automatically once the BTC confirms.
+
+
+ + +
+ +
+
You'll be redirected to a Bitcoin checkout page.
+
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..fb01c99 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Supernova — Buy Instagram, TikTok & YouTube Followers Cheap | Bitcoin SMM Panel{% endblock %} +{% block content %} +
+

Grow your reach
across the galaxy

+

Followers, likes and views for every platform — paid in Bitcoin, delivered to your handle. Pick your world, choose your boost, watch it rise.

+
+ 🌌 Explore services + {% if not user %}Create account{% endif %} +
+
+ +
+
361
services
+
18+
platforms
+
₿
Bitcoin payments
+
100%
automated
+
+ +
Spin the galaxy — choose your world
+
+
🌌
+
+ {% for name, icon in [("Instagram","📸"),("TikTok","🎵"),("YouTube","▶️"),("X / Twitter","🐦"),("Facebook","👥"),("Spotify","🎧"),("Telegram","✈️"),("Snapchat","👻")] %} + + {{ icon }} + {{ name }} + + {% endfor %} +
+
+
← drag to spin · click a planet to explore →
+ +
+

Buy followers, likes & views for every social platform

+

Supernova is a Bitcoin-powered social media marketing panel — the fastest way to grow your presence on Instagram, TikTok, YouTube, Facebook, X (Twitter), Spotify, Telegram and more. With 361 services starting from just $0.02 per 1,000, you can buy exactly the engagement you need and watch it land automatically.

+ +

Instagram

+

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.

+ +

TikTok

+

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.

+ +

YouTube

+

Buy YouTube views, subscribers, likes and watch-time. Grow your channel's authority and signal relevance to YouTube's recommendation engine.

+ +

X (Twitter) & Facebook

+

Buy X followers, retweets, likes and impressions — plus Facebook page likes, followers, comments and live-stream viewers.

+ +

Why Supernova

+
    +
  • Pay with Bitcoin — private, no KYC, instant credit.
  • +
  • Fully automated — order and delivery starts in minutes.
  • +
  • Cheap & scalable — from a few hundred to millions of units.
  • +
  • Refill & drip-feed — services with lifetime refill and gradual delivery.
  • +
+

Browse all 361 services →

+
+ + +{% endblock %} diff --git a/templates/order.html b/templates/order.html new file mode 100644 index 0000000..a441367 --- /dev/null +++ b/templates/order.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Order {{ s.name }} — Supernova{% endblock %} +{% block content %} +
+
+

{{ s.name }}

+
Category: {{ s.category }} · rate ${{ "%.2f"|format(s.rate|float) }}/1k · min {{ s.min }} · max {{ "{:,}".format(s.max) }}
+ +
+
+ + +
+ +
+ +
$0.00
+ +
{{ s.min }} units
+
+ + +
+ +
Charged from your balance. {% if s.refill %}♻️ Auto-refill included.{% endif %}
+
+
+{% endblock %} diff --git a/templates/orders.html b/templates/orders.html new file mode 100644 index 0000000..115fa05 --- /dev/null +++ b/templates/orders.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Orders — Supernova{% endblock %} +{% block content %} +
🚀 Boost launched — delivery starting!
+
Your boosts
+{% if orders %} + + + {% for o in orders %} + + + + + + + + {% endfor %} +
ServiceLinkQtyCostStatus
{{ o.service_name }}{{ o.link }}{{ "{:,}".format(o.quantity) }}${{ "%.2f"|format(o.cost) }}{{ o.status }}
+{% else %} +
+
🪐
+ +
+{% endif %} +{% endblock %} diff --git a/templates/services.html b/templates/services.html new file mode 100644 index 0000000..ffb7225 --- /dev/null +++ b/templates/services.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Services — Supernova{% endblock %} +{% block content %} +
+

Services

+

Search the catalog. Every boost is fulfilled automatically.

+
+ +
+
+ +
+ {% for cat, items in cats.items() %} +
+

{{ cat }} ({{ items|length }})

+
+ {% for s in items %} +
+

{{ s.name }}

+
min {{ s.min }} · max {{ "{:,}".format(s.max) }}
+
${{ "%.2f"|format(s.rate|float) }} / 1k
+
+ {% if s.refill %}♻️ refill{% endif %}{% if s.dripfeed %} 💧 dripfeed{% endif %}{% if not s.refill and not s.dripfeed %}·{% endif %} + Boost → +
+
+ {% endfor %} +
+
+ {% endfor %} +
+ + + + +{% endblock %}