Full SaaS deploy: control panel, market intel, profiles, real DBs
Browse files- .gitattributes +3 -0
- .gitignore +1 -2
- Dockerfile +1 -4
- README.md +4 -4
- app.py +425 -427
- data/content_pool.db +3 -0
- data/masseurs.db +3 -0
- data/unified.db +3 -0
- templates/control.html +271 -0
- templates/dashboard.html +517 -0
- templates/landing.html +272 -0
- templates/profile.html +168 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/content_pool.db filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/masseurs.db filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/unified.db filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
*.pyo
|
| 4 |
-
|
| 5 |
-
data/
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
*.pyo
|
| 4 |
+
.DS_Store
|
|
|
Dockerfile
CHANGED
|
@@ -2,13 +2,10 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
RUN pip install --no-cache-dir fastapi uvicorn[standard]
|
| 6 |
|
| 7 |
COPY . /app
|
| 8 |
|
| 9 |
-
# Create data directory for bundled DB
|
| 10 |
-
RUN mkdir -p /data
|
| 11 |
-
|
| 12 |
EXPOSE 7860
|
| 13 |
|
| 14 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
RUN pip install --no-cache-dir fastapi uvicorn[standard] jinja2
|
| 6 |
|
| 7 |
COPY . /app
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
EXPOSE 7860
|
| 10 |
|
| 11 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MasseurBoost
|
| 3 |
+
emoji: 💆
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
app.py
CHANGED
|
@@ -1,485 +1,483 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
-
import os
|
| 7 |
import json
|
| 8 |
-
import
|
| 9 |
-
import uuid
|
| 10 |
-
import hashlib
|
| 11 |
import sqlite3
|
| 12 |
-
import subprocess
|
| 13 |
-
import threading
|
| 14 |
from pathlib import Path
|
| 15 |
from datetime import datetime, timezone
|
| 16 |
-
from typing import Optional
|
| 17 |
|
| 18 |
-
from fastapi import FastAPI, Request, Query
|
| 19 |
-
from fastapi.responses import
|
| 20 |
from fastapi.staticfiles import StaticFiles
|
| 21 |
-
from
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
# ─── Paths ────────────────────────────────────────────────────
|
| 31 |
-
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(Path(__file__).parent / "data" / "workspaces")))
|
| 32 |
-
ARTIFACTS_DIR = Path(os.environ.get("ARTIFACTS_DIR", str(Path(__file__).parent / "data" / "artifacts")))
|
| 33 |
-
DB_PATH = Path(os.environ.get("KERNEL_DB", str(Path(__file__).parent / "data" / "kernel.db")))
|
| 34 |
-
|
| 35 |
-
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 36 |
-
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 37 |
-
|
| 38 |
-
# ─── SQLite for receipts, memory, tools, sessions ─────────────
|
| 39 |
-
def _init_db():
|
| 40 |
-
conn = sqlite3.connect(str(DB_PATH))
|
| 41 |
-
c = conn.cursor()
|
| 42 |
-
c.execute("""CREATE TABLE IF NOT EXISTS receipts (
|
| 43 |
-
id TEXT PRIMARY KEY, kind TEXT, workspace TEXT, summary TEXT,
|
| 44 |
-
sha256 TEXT, created_at TEXT, data_json TEXT
|
| 45 |
-
)""")
|
| 46 |
-
c.execute("""CREATE TABLE IF NOT EXISTS memory (
|
| 47 |
-
id TEXT PRIMARY KEY, topic TEXT, content TEXT, utility REAL,
|
| 48 |
-
tags TEXT, created_at TEXT
|
| 49 |
-
)""")
|
| 50 |
-
c.execute("""CREATE TABLE IF NOT EXISTS tools (
|
| 51 |
-
name TEXT PRIMARY KEY, description TEXT, mode TEXT,
|
| 52 |
-
command_template TEXT, schema_data TEXT, enabled INTEGER,
|
| 53 |
-
created_at TEXT
|
| 54 |
-
)""")
|
| 55 |
-
c.execute("""CREATE TABLE IF NOT EXISTS sessions (
|
| 56 |
-
session_id TEXT PRIMARY KEY, workspace TEXT, cwd TEXT,
|
| 57 |
-
created_at TEXT, active INTEGER
|
| 58 |
-
)""")
|
| 59 |
-
conn.commit()
|
| 60 |
-
conn.close()
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
conn.row_factory = sqlite3.Row
|
| 70 |
return conn
|
| 71 |
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
resolved = (base / path).resolve()
|
| 78 |
-
if not str(resolved).startswith(str(base.resolve())):
|
| 79 |
-
raise HTTPException(status_code=400, detail="Path traversal denied")
|
| 80 |
-
return resolved
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _truncate(text: str, limit: int = 50000) -> tuple:
|
| 84 |
-
if len(text) > limit:
|
| 85 |
-
return text[:limit], True
|
| 86 |
-
return text, False
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _receipt(kind: str, summary: str, data: dict, workspace: Optional[str] = None) -> dict:
|
| 90 |
-
rid = uuid.uuid4().hex[:16]
|
| 91 |
-
sha = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
|
| 92 |
-
now = datetime.now(timezone.utc).isoformat()
|
| 93 |
-
with DB_LOCK:
|
| 94 |
-
conn = _db()
|
| 95 |
-
conn.execute(
|
| 96 |
-
"INSERT INTO receipts VALUES (?,?,?,?,?,?,?)",
|
| 97 |
-
(rid, kind, workspace, summary, sha, now, json.dumps(data, default=str)),
|
| 98 |
-
)
|
| 99 |
-
conn.commit()
|
| 100 |
-
conn.close()
|
| 101 |
-
return {"id": rid, "kind": kind, "workspace": workspace, "summary": summary, "sha256": sha, "created_at": now}
|
| 102 |
|
| 103 |
|
| 104 |
-
def
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
)
|
| 109 |
-
stdout, truncated = _truncate(result.stdout)
|
| 110 |
-
stderr, _ = _truncate(result.stderr)
|
| 111 |
-
return {"returncode": result.returncode, "stdout": stdout, "stderr": stderr, "truncated": truncated}
|
| 112 |
-
except subprocess.TimeoutExpired:
|
| 113 |
-
return {"returncode": -1, "stdout": "", "stderr": f"Timed out after {timeout}s", "truncated": False}
|
| 114 |
-
except Exception as e:
|
| 115 |
-
return {"returncode": -1, "stdout": "", "stderr": str(e), "truncated": False}
|
| 116 |
|
| 117 |
|
| 118 |
-
#
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
workspace: str = "default"
|
| 123 |
-
command: str
|
| 124 |
-
timeout_seconds: int = Field(default=10, ge=1, le=30)
|
| 125 |
-
cwd: str = "."
|
| 126 |
-
create_receipt: bool = True
|
| 127 |
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
create_receipt: bool = True
|
| 134 |
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
content: str
|
| 151 |
-
encoding: str = "utf-8"
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
class FileReadRequest(BaseModel):
|
| 155 |
-
workspace: str = "default"
|
| 156 |
-
path: str
|
| 157 |
-
max_bytes: int = Field(default=120000, ge=1, le=20000000)
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
-
#
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
# ═══════════════════════════════════════════════════════════════
|
| 211 |
-
@app.post("/terminal/run")
|
| 212 |
-
async def run_terminal(req: TerminalRunRequest):
|
| 213 |
-
cwd = _ws_path(req.workspace, req.cwd)
|
| 214 |
-
result = _safe_run(["sh", "-c", req.command], cwd, req.timeout_seconds)
|
| 215 |
-
receipt = None
|
| 216 |
-
receipt_error = None
|
| 217 |
-
if req.create_receipt:
|
| 218 |
-
try:
|
| 219 |
-
receipt = _receipt("terminal_run", req.command[:80], result, req.workspace)
|
| 220 |
-
except Exception as e:
|
| 221 |
-
receipt_error = str(e)
|
| 222 |
-
return {"workspace": req.workspace, "cwd": req.cwd, "command": req.command, **result, "receipt": receipt, "receipt_error": receipt_error}
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
# ═══════════════��═══════════════════════════════════════════════
|
| 226 |
-
# PYTHON RUN
|
| 227 |
-
# ═══════════════════════════════════════════════════════════════
|
| 228 |
-
@app.post("/python/run")
|
| 229 |
-
async def run_python(req: PythonRunRequest):
|
| 230 |
-
ws_base = _ws_path(req.workspace)
|
| 231 |
-
script_path = ws_base / f"_run_{uuid.uuid4().hex[:8]}.py"
|
| 232 |
-
script_path.write_text(req.code)
|
| 233 |
-
result = _safe_run(["python3", str(script_path)], ws_base, req.timeout_seconds)
|
| 234 |
-
try:
|
| 235 |
-
script_path.unlink(missing_ok=True)
|
| 236 |
-
except Exception:
|
| 237 |
-
pass
|
| 238 |
-
receipt = None
|
| 239 |
-
receipt_error = None
|
| 240 |
-
if req.create_receipt:
|
| 241 |
-
try:
|
| 242 |
-
receipt = _receipt("python_run", req.code[:80], result, req.workspace)
|
| 243 |
-
except Exception as e:
|
| 244 |
-
receipt_error = str(e)
|
| 245 |
-
return {"workspace": req.workspace, "script": script_path.name, **result, "receipt": receipt, "receipt_error": receipt_error}
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
# ═══════════════════════════════════════════════════════════════
|
| 249 |
-
# SESSIONS
|
| 250 |
-
# ═══════════════════════════════════════════════════════════════
|
| 251 |
-
@app.post("/session/create")
|
| 252 |
-
async def create_session(req: SessionCreateRequest):
|
| 253 |
-
sid = uuid.uuid4().hex[:12]
|
| 254 |
-
_ws_path(req.workspace, req.cwd)
|
| 255 |
-
now = datetime.now(timezone.utc).isoformat()
|
| 256 |
-
with DB_LOCK:
|
| 257 |
-
conn = _db()
|
| 258 |
-
conn.execute("INSERT INTO sessions VALUES (?,?,?,?,1)", (sid, req.workspace, req.cwd, now))
|
| 259 |
-
conn.commit()
|
| 260 |
-
conn.close()
|
| 261 |
-
return {"session_id": sid, "workspace": req.workspace, "cwd": req.cwd, "created_at": now}
|
| 262 |
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
return {"
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
# ═══════════════════════════════════════════════════════════════
|
| 278 |
-
# FILES
|
| 279 |
-
# ═══════════════════════════════════════════════════════════════
|
| 280 |
-
@app.post("/files/write")
|
| 281 |
-
async def write_file(req: FileWriteRequest):
|
| 282 |
-
target = _ws_path(req.workspace, req.path)
|
| 283 |
-
target.parent.mkdir(parents=True, exist_ok=True)
|
| 284 |
-
target.write_text(req.content, encoding=req.encoding)
|
| 285 |
-
receipt = _receipt("file_write", req.path, {"bytes": len(req.content)}, req.workspace)
|
| 286 |
-
return {"status": "written", "path": req.path, "bytes": len(req.content), "receipt": receipt}
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
@app.post("/files/read")
|
| 290 |
-
async def read_file(req: FileReadRequest):
|
| 291 |
-
target = _ws_path(req.workspace, req.path)
|
| 292 |
-
if not target.exists():
|
| 293 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 294 |
-
if target.is_dir():
|
| 295 |
-
raise HTTPException(status_code=400, detail="Path is a directory")
|
| 296 |
-
data = target.read_bytes()[:req.max_bytes]
|
| 297 |
-
try:
|
| 298 |
-
text = data.decode("utf-8")
|
| 299 |
-
return {"path": req.path, "content": text, "bytes": len(data), "truncated": target.stat().st_size > req.max_bytes}
|
| 300 |
-
except UnicodeDecodeError:
|
| 301 |
-
import base64
|
| 302 |
-
return {"path": req.path, "content_base64": base64.b64encode(data).decode(), "bytes": len(data), "truncated": target.stat().st_size > req.max_bytes}
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
@app.post("/files/list")
|
| 306 |
-
async def list_files(req: ListRequest):
|
| 307 |
-
target = _ws_path(req.workspace, req.path)
|
| 308 |
-
if not target.exists():
|
| 309 |
-
return {"path": req.path, "entries": []}
|
| 310 |
-
entries = []
|
| 311 |
-
if target.is_dir():
|
| 312 |
-
for item in sorted(target.iterdir())[:req.max_items]:
|
| 313 |
-
entries.append({"name": item.name, "type": "dir" if item.is_dir() else "file", "size": item.stat().st_size if item.is_file() else None})
|
| 314 |
-
return {"path": req.path, "entries": entries}
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
@app.get("/workspace/tree")
|
| 318 |
-
async def get_workspace_tree(workspace: str = "default", max_items: int = 500):
|
| 319 |
-
base = _ws_path(workspace)
|
| 320 |
-
lines = []
|
| 321 |
-
count = 0
|
| 322 |
-
for p in sorted(base.rglob("*")):
|
| 323 |
-
if count >= max_items:
|
| 324 |
-
lines.append("... (truncated)")
|
| 325 |
-
break
|
| 326 |
-
rel = p.relative_to(base)
|
| 327 |
-
indent = " " * (len(rel.parts) - 1)
|
| 328 |
-
marker = "/" if p.is_dir() else ""
|
| 329 |
-
lines.append(f"{indent}{p.name}{marker}")
|
| 330 |
-
count += 1
|
| 331 |
-
return PlainTextResponse("\n".join(lines) if lines else "(empty)")
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
# ═══════════════════════════════════════════════════════════════
|
| 335 |
-
# ARTIFACTS
|
| 336 |
-
# ═══════════════════════════════════════════════════════════════
|
| 337 |
-
app.mount("/artifact", StaticFiles(directory=str(ARTIFACTS_DIR)), name="artifacts")
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
@app.post("/artifact/compile")
|
| 341 |
-
async def compile_artifact(req: ArtifactRequest):
|
| 342 |
-
artifact_hash = hashlib.sha256(req.content.encode()).hexdigest()[:16]
|
| 343 |
-
artifact_dir = ARTIFACTS_DIR / artifact_hash
|
| 344 |
-
artifact_dir.mkdir(parents=True, exist_ok=True)
|
| 345 |
-
(artifact_dir / req.filename).write_text(req.content)
|
| 346 |
-
(artifact_dir / "meta.json").write_text(json.dumps({"filename": req.filename, "content_type": req.content_type, "workspace": req.workspace, "sha256": artifact_hash, "created_at": datetime.now(timezone.utc).isoformat()}, indent=2))
|
| 347 |
-
receipt = _receipt("artifact", req.filename, {"hash": artifact_hash}, req.workspace)
|
| 348 |
-
return {"hash": artifact_hash, "filename": req.filename, "url": f"/artifact/{artifact_hash}/{req.filename}", "receipt": receipt}
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
@app.post("/url/issue")
|
| 352 |
-
async def issue_url(req: ArtifactRequest):
|
| 353 |
-
artifact_hash = hashlib.sha256(req.content.encode()).hexdigest()[:16]
|
| 354 |
-
artifact_dir = ARTIFACTS_DIR / artifact_hash
|
| 355 |
-
artifact_dir.mkdir(parents=True, exist_ok=True)
|
| 356 |
-
(artifact_dir / req.filename).write_text(req.content)
|
| 357 |
-
url = f"https://josephrw-endpoint.hf.space/artifact/{artifact_hash}/{req.filename}"
|
| 358 |
-
receipt = _receipt("url_issue", req.filename, {"url": url, "hash": artifact_hash}, req.workspace)
|
| 359 |
-
return {"url": url, "hash": artifact_hash, "filename": req.filename, "receipt": receipt}
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
# ═══════════════════════════════════════════════════════════════
|
| 363 |
-
# MEMORY
|
| 364 |
-
# ═══════════════════════════════════════════════════════════════
|
| 365 |
-
@app.post("/learn")
|
| 366 |
-
async def learn_memory(req: LearnRequest):
|
| 367 |
-
mid = uuid.uuid4().hex[:16]
|
| 368 |
-
now = datetime.now(timezone.utc).isoformat()
|
| 369 |
-
with DB_LOCK:
|
| 370 |
-
conn = _db()
|
| 371 |
-
conn.execute("INSERT INTO memory VALUES (?,?,?,?,?,?)", (mid, req.topic, req.content, req.utility, json.dumps(req.tags), now))
|
| 372 |
-
conn.commit()
|
| 373 |
-
conn.close()
|
| 374 |
-
return {"status": "learned", "id": mid, "topic": req.topic}
|
| 375 |
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
# DYNAMIC TOOLS
|
| 388 |
-
# ═══════════════════════════════════════════════════════════════
|
| 389 |
-
@app.post("/tool/register")
|
| 390 |
-
async def register_tool(req: ToolRegisterRequest):
|
| 391 |
-
now = datetime.now(timezone.utc).isoformat()
|
| 392 |
-
with DB_LOCK:
|
| 393 |
-
conn = _db()
|
| 394 |
-
conn.execute("INSERT OR REPLACE INTO tools VALUES (?,?,?,?,?,?,?)", (req.name, req.description, req.mode, req.command_template, json.dumps(req.schema_data), int(req.enabled), now))
|
| 395 |
-
conn.commit()
|
| 396 |
-
conn.close()
|
| 397 |
-
return {"status": "registered", "name": req.name, "mode": req.mode}
|
| 398 |
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
|
| 408 |
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
cwd = _ws_path(req.workspace)
|
| 429 |
-
result = _safe_run(["sh", "-c", cmd], cwd, req.timeout_seconds)
|
| 430 |
-
receipt = _receipt("tool_invoke", name, result, req.workspace)
|
| 431 |
-
return {"tool": name, **result, "receipt": receipt}
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
# ═══════════════════════════════════════════════════════════════
|
| 435 |
-
# RECEIPTS / LEDGER
|
| 436 |
-
# ═══════════════════════════════════════════════════════════════
|
| 437 |
-
@app.get("/ledger/recent")
|
| 438 |
-
async def get_recent_ledger(limit: int = 50):
|
| 439 |
-
with DB_LOCK:
|
| 440 |
-
conn = _db()
|
| 441 |
-
rows = conn.execute("SELECT id, kind, workspace, summary, sha256, created_at FROM receipts ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
| 442 |
-
conn.close()
|
| 443 |
-
return {"receipts": [{"id": r["id"], "kind": r["kind"], "workspace": r["workspace"], "summary": r["summary"], "sha256": r["sha256"], "created_at": r["created_at"]} for r in rows]}
|
| 444 |
|
| 445 |
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
|
| 456 |
|
| 457 |
-
#
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
return {
|
| 463 |
-
"
|
| 464 |
-
"
|
| 465 |
-
"endpoints": [
|
| 466 |
-
"GET /health",
|
| 467 |
-
"POST /terminal/run",
|
| 468 |
-
"POST /python/run",
|
| 469 |
-
"POST /session/create",
|
| 470 |
-
"POST /session/run",
|
| 471 |
-
"POST /files/write",
|
| 472 |
-
"POST /files/read",
|
| 473 |
-
"POST /files/list",
|
| 474 |
-
"GET /workspace/tree",
|
| 475 |
-
"POST /artifact/compile",
|
| 476 |
-
"POST /url/issue",
|
| 477 |
-
"POST /learn",
|
| 478 |
-
"POST /recall",
|
| 479 |
-
"POST /tool/register",
|
| 480 |
-
"GET /tools",
|
| 481 |
-
"POST /tool/{name}",
|
| 482 |
-
"GET /ledger/recent",
|
| 483 |
-
"GET /receipt/{receipt_id}",
|
| 484 |
-
],
|
| 485 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MasseurBoost — Full SaaS deployed on Hugging Face Spaces.
|
| 2 |
|
| 3 |
+
Self-contained: embeds real scraped databases, serves market intelligence,
|
| 4 |
+
profile pages, orchestrator status (read-only), and control panel UI.
|
| 5 |
"""
|
|
|
|
| 6 |
import json
|
| 7 |
+
import os
|
|
|
|
|
|
|
| 8 |
import sqlite3
|
|
|
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
from datetime import datetime, timezone
|
|
|
|
| 11 |
|
| 12 |
+
from fastapi import FastAPI, Request, Query
|
| 13 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 14 |
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from fastapi.templating import Jinja2Templates
|
| 16 |
+
|
| 17 |
+
BASE_DIR = Path(__file__).parent
|
| 18 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 19 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 20 |
+
DATA_DIR = BASE_DIR / "data"
|
| 21 |
+
|
| 22 |
+
app = FastAPI(title="MasseurBoost", docs_url="/api/docs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 25 |
+
if STATIC_DIR.exists():
|
| 26 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 27 |
|
| 28 |
+
MASSEURS_DB = DATA_DIR / "masseurs.db"
|
| 29 |
+
UNIFIED_DB = DATA_DIR / "unified.db"
|
| 30 |
+
CONTENT_POOL_DB = DATA_DIR / "content_pool.db"
|
| 31 |
|
| 32 |
+
RENTMASSEUR_BASE = "https://rentmasseur.com"
|
| 33 |
|
| 34 |
+
|
| 35 |
+
def get_masseurs_db():
|
| 36 |
+
conn = sqlite3.connect(str(MASSEURS_DB))
|
| 37 |
conn.row_factory = sqlite3.Row
|
| 38 |
return conn
|
| 39 |
|
| 40 |
|
| 41 |
+
def get_unified_db():
|
| 42 |
+
conn = sqlite3.connect(str(UNIFIED_DB))
|
| 43 |
+
conn.row_factory = sqlite3.Row
|
| 44 |
+
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
+
def get_content_pool_db():
|
| 48 |
+
conn = sqlite3.connect(str(CONTENT_POOL_DB))
|
| 49 |
+
conn.row_factory = sqlite3.Row
|
| 50 |
+
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
+
# ─── Landing page ───────────────────────────────────────────
|
| 54 |
+
@app.get("/", response_class=HTMLResponse)
|
| 55 |
+
async def landing(request: Request):
|
| 56 |
+
return templates.TemplateResponse(request, "landing.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
+
# ─── Dashboard ──────────────────────────────────────────────
|
| 60 |
+
@app.get("/dashboard", response_class=HTMLResponse)
|
| 61 |
+
async def dashboard(request: Request):
|
| 62 |
+
return templates.TemplateResponse(request, "dashboard.html")
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
+
# ─── Control Panel ──────────────────────────────────────────
|
| 66 |
+
@app.get("/control", response_class=HTMLResponse)
|
| 67 |
+
async def control_panel(request: Request):
|
| 68 |
+
return templates.TemplateResponse(request, "control.html")
|
| 69 |
|
| 70 |
|
| 71 |
+
# ─── Profile page ───────────────────────────────────────────
|
| 72 |
+
@app.get("/profile/{username}", response_class=HTMLResponse)
|
| 73 |
+
async def profile_page(request: Request, username: str):
|
| 74 |
+
conn = get_masseurs_db()
|
| 75 |
+
c = conn.cursor()
|
| 76 |
+
row = c.execute("SELECT * FROM masseurs WHERE username = ?", (username,)).fetchone()
|
| 77 |
+
if not row:
|
| 78 |
+
conn.close()
|
| 79 |
+
return templates.TemplateResponse(request, "profile.html", {"error": True, "username": username})
|
| 80 |
+
profile = dict(row)
|
| 81 |
+
total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
|
| 82 |
+
rank = c.execute("SELECT COUNT(*) FROM masseurs WHERE views_per_day > ?", (profile["views_per_day"],)).fetchone()[0] + 1
|
| 83 |
+
percentile = round((1 - rank / total) * 100, 1)
|
| 84 |
+
city_peers = c.execute(
|
| 85 |
+
"SELECT username, views_per_day, visits FROM masseurs WHERE city = ? AND username != ? ORDER BY views_per_day DESC LIMIT 5",
|
| 86 |
+
(profile["city"], username),
|
| 87 |
+
).fetchall()
|
| 88 |
+
conn.close()
|
| 89 |
+
return templates.TemplateResponse(request, "profile.html", {
|
| 90 |
+
"error": False,
|
| 91 |
+
"profile": profile,
|
| 92 |
+
"rank": rank,
|
| 93 |
+
"total": total,
|
| 94 |
+
"percentile": percentile,
|
| 95 |
+
"city_peers": [dict(r) for r in city_peers],
|
| 96 |
+
"services": json.loads(profile.get("services", "[]")),
|
| 97 |
+
"rentmasseur_url": f"{RENTMASSEUR_BASE}/{username}",
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ─── API: Market stats ──────────────────────────────────────
|
| 102 |
+
@app.get("/api/market-stats")
|
| 103 |
+
async def market_stats():
|
| 104 |
+
conn = get_masseurs_db()
|
| 105 |
+
c = conn.cursor()
|
| 106 |
+
total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
|
| 107 |
+
cities = c.execute("SELECT COUNT(DISTINCT city) FROM masseurs").fetchone()[0]
|
| 108 |
+
gold = c.execute("SELECT COUNT(*) FROM masseurs WHERE is_gold=1").fetchone()[0]
|
| 109 |
+
avg_vpd = c.execute("SELECT AVG(views_per_day) FROM masseurs WHERE views_per_day > 0").fetchone()[0]
|
| 110 |
+
median_vpd = c.execute("SELECT views_per_day FROM masseurs WHERE views_per_day > 0 ORDER BY views_per_day").fetchall()
|
| 111 |
+
median = median_vpd[len(median_vpd)//2][0] if median_vpd else 0
|
| 112 |
|
| 113 |
+
top_cities = c.execute(
|
| 114 |
+
"SELECT city, COUNT(*) as cnt, AVG(views_per_day) as avg_vpd FROM masseurs GROUP BY city ORDER BY cnt DESC LIMIT 10"
|
| 115 |
+
).fetchall()
|
| 116 |
|
| 117 |
+
top_masseurs = c.execute(
|
| 118 |
+
"SELECT username, city, views_per_day, visits, rating, reviews_count, is_gold, photo_count FROM masseurs ORDER BY views_per_day DESC LIMIT 10"
|
| 119 |
+
).fetchall()
|
|
|
|
|
|
|
| 120 |
|
| 121 |
+
conn.close()
|
| 122 |
+
return {
|
| 123 |
+
"total_masseurs": total,
|
| 124 |
+
"total_cities": cities,
|
| 125 |
+
"gold_members": gold,
|
| 126 |
+
"avg_views_per_day": round(avg_vpd or 0, 1),
|
| 127 |
+
"median_views_per_day": median,
|
| 128 |
+
"top_cities": [{"city": r[0], "count": r[1], "avg_vpd": round(r[2] or 0, 1)} for r in top_cities],
|
| 129 |
+
"top_masseurs": [
|
| 130 |
+
{"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3], "rating": r[4],
|
| 131 |
+
"reviews": r[5], "gold": bool(r[6]), "photos": r[7],
|
| 132 |
+
"profile_url": f"{RENTMASSEUR_BASE}/{r[0]}",
|
| 133 |
+
"internal_url": f"/profile/{r[0]}"}
|
| 134 |
+
for r in top_masseurs
|
| 135 |
+
],
|
| 136 |
+
}
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
# ─── API: Search masseurs ───────────────────────────────────
|
| 140 |
+
@app.get("/api/search")
|
| 141 |
+
async def search_masseurs(
|
| 142 |
+
city: str = Query("", description="Filter by city"),
|
| 143 |
+
q: str = Query("", description="Search by username or city"),
|
| 144 |
+
limit: int = Query(20, le=100),
|
| 145 |
+
offset: int = Query(0),
|
| 146 |
+
sort: str = Query("views_per_day", description="Sort field"),
|
| 147 |
+
):
|
| 148 |
+
conn = get_masseurs_db()
|
| 149 |
+
c = conn.cursor()
|
| 150 |
+
where = "WHERE 1=1"
|
| 151 |
+
params = []
|
| 152 |
+
if city:
|
| 153 |
+
where += " AND city = ?"
|
| 154 |
+
params.append(city)
|
| 155 |
+
if q:
|
| 156 |
+
where += " AND (username LIKE ? OR city LIKE ?)"
|
| 157 |
+
params += [f"%{q}%", f"%{q}%"]
|
| 158 |
+
|
| 159 |
+
valid_sorts = {"views_per_day", "visits", "reviews_count", "rating", "bio"}
|
| 160 |
+
sort = sort if sort in valid_sorts else "views_per_day"
|
| 161 |
+
|
| 162 |
+
total = c.execute(f"SELECT COUNT(*) FROM masseurs {where}", params).fetchone()[0]
|
| 163 |
+
rows = c.execute(
|
| 164 |
+
f"SELECT username, city, views_per_day, visits, since_date, rating, reviews_count, is_gold, photo_count FROM masseurs {where} ORDER BY {sort} DESC LIMIT ? OFFSET ?",
|
| 165 |
+
params + [limit, offset],
|
| 166 |
+
).fetchall()
|
| 167 |
|
| 168 |
+
conn.close()
|
| 169 |
+
return {
|
| 170 |
+
"total": total,
|
| 171 |
+
"results": [
|
| 172 |
+
{"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3], "since": r[4],
|
| 173 |
+
"rating": r[5], "reviews": r[6], "gold": bool(r[7]), "photos": r[8],
|
| 174 |
+
"profile_url": f"{RENTMASSEUR_BASE}/{r[0]}",
|
| 175 |
+
"internal_url": f"/profile/{r[0]}"}
|
| 176 |
+
for r in rows
|
| 177 |
+
],
|
| 178 |
+
}
|
| 179 |
|
| 180 |
|
| 181 |
+
# ─── API: Cities list ───────────────────────────────────────
|
| 182 |
+
@app.get("/api/cities")
|
| 183 |
+
async def cities():
|
| 184 |
+
conn = get_masseurs_db()
|
| 185 |
+
c = conn.cursor()
|
| 186 |
+
rows = c.execute(
|
| 187 |
+
"SELECT city, COUNT(*) as cnt FROM masseurs GROUP BY city ORDER BY cnt DESC"
|
| 188 |
+
).fetchall()
|
| 189 |
+
conn.close()
|
| 190 |
+
return {"cities": [{"name": r[0], "count": r[1]} for r in rows]}
|
| 191 |
|
| 192 |
|
| 193 |
+
# ─── API: Orchestrator status (read-only on HF) ─────────────
|
| 194 |
+
@app.get("/api/orchestrator/status")
|
| 195 |
+
async def orch_status():
|
| 196 |
+
return {
|
| 197 |
+
"daemon_running": False,
|
| 198 |
+
"daemon_note": "Running on Hugging Face Spaces — daemon operates from local Mac. Visit local control panel for live daemon control.",
|
| 199 |
+
"tasks": [],
|
| 200 |
+
"log_tail": [],
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ─── API: DB stats ──────────────────────────────────────────
|
| 205 |
+
@app.get("/api/orchestrator/db-stats")
|
| 206 |
+
async def db_stats():
|
| 207 |
+
stats = {}
|
| 208 |
+
if UNIFIED_DB.exists():
|
| 209 |
+
u = get_unified_db()
|
| 210 |
+
stats["visitors"] = u.execute("SELECT COUNT(*) FROM visitors").fetchone()[0]
|
| 211 |
+
stats["visit_log"] = u.execute("SELECT COUNT(*) FROM visit_log").fetchone()[0]
|
| 212 |
+
stats["hourly_kpis"] = u.execute("SELECT COUNT(*) FROM hourly_kpis").fetchone()[0]
|
| 213 |
+
stats["profile_stats"] = u.execute("SELECT COUNT(*) FROM my_profile_stats").fetchone()[0]
|
| 214 |
+
try:
|
| 215 |
+
stats["bio_experiments"] = u.execute("SELECT COUNT(*) FROM bio_experiments").fetchone()[0]
|
| 216 |
+
except Exception:
|
| 217 |
+
stats["bio_experiments"] = 0
|
| 218 |
+
try:
|
| 219 |
+
stats["blog_posts"] = u.execute("SELECT COUNT(*) FROM blog_posts").fetchone()[0]
|
| 220 |
+
except Exception:
|
| 221 |
+
stats["blog_posts"] = 0
|
| 222 |
+
try:
|
| 223 |
+
stats["interview_sets"] = u.execute("SELECT COUNT(*) FROM interview_sets").fetchone()[0]
|
| 224 |
+
except Exception:
|
| 225 |
+
stats["interview_sets"] = 0
|
| 226 |
+
try:
|
| 227 |
+
stats["receipts"] = u.execute("SELECT COUNT(*) FROM my_receipts").fetchone()[0]
|
| 228 |
+
except Exception:
|
| 229 |
+
stats["receipts"] = 0
|
| 230 |
+
row = u.execute("SELECT * FROM hourly_kpis ORDER BY id DESC LIMIT 1").fetchone()
|
| 231 |
+
if row:
|
| 232 |
+
cols = [d[0] for d in u.execute("SELECT * FROM hourly_kpis LIMIT 0").description]
|
| 233 |
+
stats["latest_kpi"] = dict(zip(cols, row))
|
| 234 |
+
u.close()
|
| 235 |
+
if MASSEURS_DB.exists():
|
| 236 |
+
m = get_masseurs_db()
|
| 237 |
+
stats["masseurs_scraped"] = m.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
|
| 238 |
+
stats["cities_scraped"] = m.execute("SELECT COUNT(DISTINCT city) FROM masseurs").fetchone()[0]
|
| 239 |
+
m.close()
|
| 240 |
+
if CONTENT_POOL_DB.exists():
|
| 241 |
+
cp = get_content_pool_db()
|
| 242 |
+
stats["content_variants"] = cp.execute("SELECT COUNT(*) FROM content_pool").fetchone()[0]
|
| 243 |
+
stats["competitor_benchmarks"] = cp.execute("SELECT COUNT(*) FROM competitor_benchmarks").fetchone()[0]
|
| 244 |
+
cp.close()
|
| 245 |
+
return stats
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ─── API: Orchestrator logs (read from embedded state) ──────
|
| 249 |
+
@app.get("/api/orchestrator/logs")
|
| 250 |
+
async def orch_logs(limit: int = 100):
|
| 251 |
+
return {"logs": [], "note": "Logs available on local control panel"}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ─── API: Trigger tasks (disabled on HF) ────────────────────
|
| 255 |
+
@app.post("/api/orchestrator/trigger/{task_name}")
|
| 256 |
+
async def orch_trigger(task_name: str):
|
| 257 |
+
return JSONResponse(
|
| 258 |
+
{"error": "Task triggering is only available on the local control panel (localhost:8000)"},
|
| 259 |
+
status_code=503,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@app.post("/api/orchestrator/daemon/start")
|
| 264 |
+
async def daemon_start():
|
| 265 |
+
return JSONResponse(
|
| 266 |
+
{"error": "Daemon control is only available on the local control panel (localhost:8000)"},
|
| 267 |
+
status_code=503,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@app.post("/api/orchestrator/daemon/stop")
|
| 272 |
+
async def daemon_stop():
|
| 273 |
+
return JSONResponse(
|
| 274 |
+
{"error": "Daemon control is only available on the local control panel (localhost:8000)"},
|
| 275 |
+
status_code=503,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ─── API: Competitors by city ───────────────────────────────
|
| 280 |
+
@app.get("/api/competitors/{city}")
|
| 281 |
+
async def competitors(city: str, limit: int = Query(20, le=100)):
|
| 282 |
+
conn = get_masseurs_db()
|
| 283 |
+
c = conn.cursor()
|
| 284 |
+
rows = c.execute(
|
| 285 |
+
"SELECT username, city, views_per_day, visits, rating, reviews_count, is_gold, photo_count FROM masseurs WHERE city = ? ORDER BY views_per_day DESC LIMIT ?",
|
| 286 |
+
(city, limit),
|
| 287 |
+
).fetchall()
|
| 288 |
+
conn.close()
|
| 289 |
+
return {
|
| 290 |
+
"city": city,
|
| 291 |
+
"count": len(rows),
|
| 292 |
+
"competitors": [
|
| 293 |
+
{"username": r[0], "city": r[1], "vpd": r[2], "visits": r[3],
|
| 294 |
+
"rating": r[4], "reviews": r[5], "gold": bool(r[6]), "photos": r[7],
|
| 295 |
+
"profile_url": f"{RENTMASSEUR_BASE}/{r[0]}"}
|
| 296 |
+
for r in rows
|
| 297 |
+
],
|
| 298 |
+
}
|
| 299 |
|
| 300 |
|
| 301 |
+
# ─── API: Optimization status ───────────────────────────────
|
| 302 |
+
@app.get("/api/optimization-status")
|
| 303 |
+
async def optimization_status():
|
| 304 |
+
return {
|
| 305 |
+
"engine": "Genetic Algorithm + Reinforcement Learning + LLM",
|
| 306 |
+
"components": [
|
| 307 |
+
{"name": "Bio Optimizer", "status": "active", "description": "GA evolves bio text, RL tracks engagement, LLM generates mutations"},
|
| 308 |
+
{"name": "Blog Generator", "status": "active", "description": "LLM generates SEO-optimized blog posts from winning patterns"},
|
| 309 |
+
{"name": "Photo Rotator", "status": "active", "description": "GA tests photo order combinations for max click-through"},
|
| 310 |
+
{"name": "Availability Engine", "status": "active", "description": "RL optimizes availability windows for booking conversion"},
|
| 311 |
+
{"name": "Interview Rotator", "status": "active", "description": "LLM generates funny Q&A that drive engagement"},
|
| 312 |
+
{"name": "Price Optimizer", "status": "active", "description": "GA tests price points for revenue maximization"},
|
| 313 |
+
],
|
| 314 |
+
"cycle": "Every 3 hours: collect stats → evaluate fitness → mutate → push winners → repeat",
|
| 315 |
+
}
|
| 316 |
|
| 317 |
|
| 318 |
+
# ─── API: Blog topics ───────────────────────────────────────
|
| 319 |
+
@app.get("/api/blog/topics")
|
| 320 |
+
async def blog_topics():
|
| 321 |
+
topics = [
|
| 322 |
+
{"id": "perfect_session", "title": "The Perfect Session", "angle": "What makes a 5-star massage experience"},
|
| 323 |
+
{"id": "client_etiquette", "title": "Client Etiquette 101", "angle": "How to be the client therapists love"},
|
| 324 |
+
{"id": "deep_tissue_magic", "title": "Deep Tissue Magic", "angle": "Why deep tissue isn't scary"},
|
| 325 |
+
{"id": "midnight_massage", "title": "Midnight Massage Stories", "angle": "The weird and wonderful night crowd"},
|
| 326 |
+
{"id": "luxury_experience", "title": "Luxury on a Budget", "angle": "Premium feel without the spa price"},
|
| 327 |
+
{"id": "recovery_secrets", "title": "Recovery Secrets", "angle": "Post-workout massage for athletes"},
|
| 328 |
+
{"id": "first_time_guide", "title": "First Time? Here's What to Expect", "angle": "Beginner-friendly guide"},
|
| 329 |
+
{"id": "stress_relief", "title": "Stress Relief That Actually Works", "angle": "Why massage beats meditation"},
|
| 330 |
+
{"id": "weekend_reset", "title": "The Weekend Reset", "angle": "Friday massage = Monday productivity"},
|
| 331 |
+
{"id": "funniest_requests", "title": "Funniest Client Requests", "angle": "Humor drives engagement and bookings"},
|
| 332 |
+
]
|
| 333 |
+
return {"topics": topics}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ─── API: Photo strategy ────────────────────────────────────
|
| 337 |
+
@app.get("/api/photo-strategy")
|
| 338 |
+
async def photo_strategy():
|
| 339 |
+
return {
|
| 340 |
+
"strategy": "organism",
|
| 341 |
+
"description": "Your profile photos rotate automatically based on time of day, day of week, and engagement patterns.",
|
| 342 |
+
"schedule": [
|
| 343 |
+
{"time": "Morning (6am-12pm)", "type": "Professional/clinical shots", "reason": "Business professionals browsing before work"},
|
| 344 |
+
{"time": "Afternoon (12pm-6pm)", "type": "Action/massage technique shots", "reason": "Lunch break browsers want to see skills"},
|
| 345 |
+
{"time": "Evening (6pm-12am)", "type": "Lifestyle/relaxation shots", "reason": "After-work crowd wants to envision the experience"},
|
| 346 |
+
{"time": "Late night (12am-6am)", "type": "Mood/atmosphere shots", "reason": "Night owls respond to ambiance over clinical"},
|
| 347 |
+
],
|
| 348 |
+
"rotation_frequency": "Every 6 hours",
|
| 349 |
+
"ai_optimization": "GA optimizer tests photo order combinations and picks the one with highest click-through rate",
|
| 350 |
+
}
|
| 351 |
|
| 352 |
|
| 353 |
+
# ─── API: Availability gravity ──────────────────────────────
|
| 354 |
+
@app.get("/api/availability-gravity")
|
| 355 |
+
async def availability_gravity():
|
| 356 |
+
return {
|
| 357 |
+
"feature": "Availability Gravity",
|
| 358 |
+
"description": "Dynamic availability updates that create urgency and drive repeat visits.",
|
| 359 |
+
"tactics": [
|
| 360 |
+
{"name": "Scarcity Windows", "description": "Show limited time slots to create urgency", "impact": "Increases booking conversion by 40-60%"},
|
| 361 |
+
{"name": "Dynamic Status", "description": "Auto-update status to 'Available Now' during peak browsing hours", "impact": "Captures impulse bookings"},
|
| 362 |
+
{"name": "Revisit Triggers", "description": "Rotate availability display so returning visitors see new slots", "impact": "Increases daily return visits by 25%"},
|
| 363 |
+
{"name": "Peak Hour Alignment", "description": "Open availability slots during historically high-traffic periods", "impact": "Maximizes visibility when most clients are browsing"},
|
| 364 |
+
],
|
| 365 |
+
"rl_optimization": "Reinforcement learning loop tracks which availability patterns drive the most bookings and auto-adjusts.",
|
| 366 |
+
}
|
| 367 |
|
| 368 |
|
| 369 |
+
# ─── API: Pricing ───────────────────────────────────────────
|
| 370 |
+
@app.get("/api/pricing")
|
| 371 |
+
async def pricing():
|
| 372 |
+
return {
|
| 373 |
+
"plans": [
|
| 374 |
+
{"name": "Starter", "price": "$49/mo", "features": ["AI-optimized bio (monthly refresh)", "2 blog posts per month", "Basic photo rotation", "Market intelligence report", "Email support"]},
|
| 375 |
+
{"name": "Professional", "price": "$149/mo", "popular": True, "features": ["GA/RL bio optimization (weekly A/B testing)", "4 blog posts per month", "Smart photo rotation", "Availability gravity engine", "Interview Q&A rotation", "Competitor benchmarking", "Priority support"]},
|
| 376 |
+
{"name": "Elite", "price": "$399/mo", "features": ["Full autonomous optimization", "Unlimited blog posts + interviews", "Advanced photo rotation (GA-optimized)", "Full availability gravity + price optimization", "Worldwide competitor intelligence", "Custom LLM fine-tuning", "Dedicated account manager", "API access"]},
|
| 377 |
+
]
|
| 378 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
|
| 381 |
+
# ─── API: Bio preview ───────────────────────────────────────
|
| 382 |
+
@app.get("/api/bio-preview")
|
| 383 |
+
async def bio_preview(style: str = Query("balanced")):
|
| 384 |
+
previews = {
|
| 385 |
+
"balanced": "Certified massage therapist with years of experience in deep tissue, Swedish, and therapeutic techniques. Discreet, professional, and dedicated to your relaxation. Available now in Manhattan — call to book your session today.",
|
| 386 |
+
"funny": "I'm not saying my hands are magic, but clients have been known to forget their own name mid-massage. Certified, discreet, and surprisingly funny for someone who works in silence. Available now — your muscles are begging you to call.",
|
| 387 |
+
"luxury": "An exclusive massage experience for the discerning gentleman. Private studio, premium oils, and techniques refined over thousands of sessions. When only the best will do. By appointment only — call now to secure your time slot.",
|
| 388 |
+
"clinical": "Licensed massage therapist specializing in deep tissue recovery, sports massage, and chronic pain management. Evidence-based techniques, professional environment, proven results. Direct booking available — call to schedule your consultation.",
|
| 389 |
+
"mysterious": "Some experiences can't be described — only felt. Step into a world of sensory exploration where every touch tells a story. Discreet, exclusive, unforgettable. The key is in your hands — call to unlock.",
|
| 390 |
+
}
|
| 391 |
+
return {"style": style, "preview": previews.get(style, previews["balanced"])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
|
| 394 |
+
# ─── API: Health ────────────────────────────────────────────
|
| 395 |
+
@app.get("/api/health")
|
| 396 |
+
async def health():
|
| 397 |
+
return {
|
| 398 |
+
"status": "ok",
|
| 399 |
+
"masseurs_db": str(MASSEURS_DB),
|
| 400 |
+
"masseurs_db_exists": MASSEURS_DB.exists(),
|
| 401 |
+
"unified_db_exists": UNIFIED_DB.exists(),
|
| 402 |
+
"content_pool_db_exists": CONTENT_POOL_DB.exists(),
|
| 403 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
|
| 406 |
+
# ─── API: Visitors data ─────────────────────────────────────
|
| 407 |
+
@app.get("/api/visitors")
|
| 408 |
+
async def visitors(limit: int = Query(50, le=200)):
|
| 409 |
+
conn = get_unified_db()
|
| 410 |
+
c = conn.cursor()
|
| 411 |
+
rows = c.execute(
|
| 412 |
+
"SELECT username, visit_count, last_online, profile_url, profile_hash FROM visitors ORDER BY visit_count DESC LIMIT ?",
|
| 413 |
+
(limit,),
|
| 414 |
+
).fetchall()
|
| 415 |
+
conn.close()
|
| 416 |
+
return {
|
| 417 |
+
"count": len(rows),
|
| 418 |
+
"visitors": [
|
| 419 |
+
{"username": r[0], "visit_count": r[1], "last_online": r[2],
|
| 420 |
+
"profile_url": r[3], "profile_hash": r[4]}
|
| 421 |
+
for r in rows
|
| 422 |
+
],
|
| 423 |
+
}
|
| 424 |
|
| 425 |
|
| 426 |
+
# ─── API: KPI history ───────────────────────────────────────
|
| 427 |
+
@app.get("/api/kpis")
|
| 428 |
+
async def kpi_history(limit: int = Query(50, le=200)):
|
| 429 |
+
conn = get_unified_db()
|
| 430 |
+
c = conn.cursor()
|
| 431 |
+
rows = c.execute(
|
| 432 |
+
"SELECT id, timestamp, immortality_score, immortality_grade, virality_score, virality_grade, profile_views, contact_clicks, new_visits, views_per_day FROM hourly_kpis ORDER BY id DESC LIMIT ?",
|
| 433 |
+
(limit,),
|
| 434 |
+
).fetchall()
|
| 435 |
+
conn.close()
|
| 436 |
+
return {
|
| 437 |
+
"count": len(rows),
|
| 438 |
+
"kpis": [
|
| 439 |
+
{"id": r[0], "timestamp": r[1], "immortality_score": r[2], "immortality_grade": r[3],
|
| 440 |
+
"virality_score": r[4], "virality_grade": r[5], "profile_views": r[6],
|
| 441 |
+
"contact_clicks": r[7], "new_visits": r[8], "views_per_day": r[9]}
|
| 442 |
+
for r in rows
|
| 443 |
+
],
|
| 444 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
|
| 447 |
+
# ─── API: Content pool ──────────────────────────────────────
|
| 448 |
+
@app.get("/api/content-pool")
|
| 449 |
+
async def content_pool(limit: int = Query(50, le=200)):
|
| 450 |
+
conn = get_content_pool_db()
|
| 451 |
+
c = conn.cursor()
|
| 452 |
+
rows = c.execute(
|
| 453 |
+
"SELECT * FROM content_pool ORDER BY benchmark_rank ASC LIMIT ?",
|
| 454 |
+
(limit,),
|
| 455 |
+
).fetchall()
|
| 456 |
+
conn.close()
|
| 457 |
+
cols = [d[0] for d in c.description] if c.description else []
|
| 458 |
+
return {
|
| 459 |
+
"count": len(rows),
|
| 460 |
+
"content": [dict(zip(cols, r)) for r in rows],
|
| 461 |
+
}
|
| 462 |
|
| 463 |
|
| 464 |
+
# ─── API: Competitor benchmarks ─────────────────────────────
|
| 465 |
+
@app.get("/api/competitor-benchmarks")
|
| 466 |
+
async def competitor_benchmarks(limit: int = Query(50, le=200)):
|
| 467 |
+
conn = get_content_pool_db()
|
| 468 |
+
c = conn.cursor()
|
| 469 |
+
rows = c.execute(
|
| 470 |
+
"SELECT * FROM competitor_benchmarks ORDER BY id DESC LIMIT ?",
|
| 471 |
+
(limit,),
|
| 472 |
+
).fetchall()
|
| 473 |
+
conn.close()
|
| 474 |
+
cols = [d[0] for d in c.description] if c.description else []
|
| 475 |
return {
|
| 476 |
+
"count": len(rows),
|
| 477 |
+
"benchmarks": [dict(zip(cols, r)) for r in rows],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
if __name__ == "__main__":
|
| 482 |
+
import uvicorn
|
| 483 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
data/content_pool.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fb715937575fa2e102eaf8f3d93e2ffa743619929cf355a042e3da5246d2644c
|
| 3 |
+
size 225280
|
data/masseurs.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:751f7531cdb8e6d02d1a3d54bc96165a4293acfade1f1df474e5b40fbf6386f5
|
| 3 |
+
size 6619136
|
data/unified.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e47431dbbbf3d37d0a62bc4f65fe6c0c15c452a88cecdbd7a562abd6cce8187a
|
| 3 |
+
size 1658880
|
templates/control.html
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>MasseurBoost — Orchestrator Control Panel</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0a0e17; --card: #131825; --border: #1e2a3d; --text: #e4e7ee;
|
| 10 |
+
--muted: #6b7488; --accent: #4f9eff; --green: #22c55e; --red: #ef4444;
|
| 11 |
+
--yellow: #eab923; --orange: #f97316; --purple: #a78bfa;
|
| 12 |
+
}
|
| 13 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 14 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
| 15 |
+
|
| 16 |
+
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
|
| 17 |
+
.header h1 { font-size: 20px; font-weight: 700; }
|
| 18 |
+
.header h1 span { color: var(--accent); }
|
| 19 |
+
.header nav a { color: var(--muted); text-decoration: none; margin-left: 20px; font-size: 14px; }
|
| 20 |
+
.header nav a:hover { color: var(--text); }
|
| 21 |
+
.header nav a.active { color: var(--accent); }
|
| 22 |
+
|
| 23 |
+
.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
| 24 |
+
|
| 25 |
+
/* Daemon status bar */
|
| 26 |
+
.daemon-bar { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; }
|
| 27 |
+
.daemon-status { display: flex; align-items: center; gap: 12px; }
|
| 28 |
+
.daemon-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--red); transition: background 0.3s; }
|
| 29 |
+
.daemon-dot.running { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 2s infinite; }
|
| 30 |
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
| 31 |
+
.daemon-text { font-size: 16px; font-weight: 600; }
|
| 32 |
+
.daemon-text small { color: var(--muted); font-weight: 400; font-size: 13px; margin-left: 8px; }
|
| 33 |
+
.btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
| 34 |
+
.btn-start { background: var(--green); color: #fff; }
|
| 35 |
+
.btn-stop { background: var(--red); color: #fff; }
|
| 36 |
+
.btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
| 37 |
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
| 38 |
+
|
| 39 |
+
/* Stats grid */
|
| 40 |
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
| 41 |
+
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; text-align: center; }
|
| 42 |
+
.stat-card .num { font-size: 28px; font-weight: 700; color: var(--accent); }
|
| 43 |
+
.stat-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
| 44 |
+
|
| 45 |
+
/* Task table */
|
| 46 |
+
.section-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
| 47 |
+
.section-title .badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--border); color: var(--muted); }
|
| 48 |
+
|
| 49 |
+
.task-table { background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
| 50 |
+
.task-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 0.8fr 0.8fr; padding: 14px 20px; border-bottom: 1px solid var(--border); align-items: center; font-size: 14px; }
|
| 51 |
+
.task-row:last-child { border-bottom: none; }
|
| 52 |
+
.task-row.header-row { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
| 53 |
+
.task-row:hover { background: rgba(79, 158, 255, 0.05); }
|
| 54 |
+
|
| 55 |
+
.task-name { font-weight: 600; }
|
| 56 |
+
.task-name .icon { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
|
| 57 |
+
.task-name .icon.due { background: var(--yellow); }
|
| 58 |
+
.task-name .icon.not-due { background: var(--green); }
|
| 59 |
+
.task-name .icon.running { background: var(--accent); animation: pulse 1s infinite; }
|
| 60 |
+
|
| 61 |
+
.status-pill { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
|
| 62 |
+
.status-pill.due { background: rgba(234, 185, 35, 0.15); color: var(--yellow); }
|
| 63 |
+
.status-pill.ok { background: rgba(34, 197, 94, 0.15); color: var(--green); }
|
| 64 |
+
.status-pill.running { background: rgba(79, 158, 255, 0.15); color: var(--accent); }
|
| 65 |
+
.status-pill.idle { background: rgba(107, 116, 136, 0.15); color: var(--muted); }
|
| 66 |
+
|
| 67 |
+
.btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 12px; }
|
| 68 |
+
.btn-trigger { background: var(--accent); color: #fff; }
|
| 69 |
+
.btn-trigger:disabled { background: var(--border); color: var(--muted); }
|
| 70 |
+
|
| 71 |
+
/* Log feed */
|
| 72 |
+
.log-feed { background: #0d1117; border: 1px solid var(--border); border-radius: 12px; padding: 16px; max-height: 400px; overflow-y: auto; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
| 73 |
+
.log-entry { padding: 4px 0; border-bottom: 1px solid rgba(30, 42, 61, 0.5); display: flex; gap: 12px; }
|
| 74 |
+
.log-entry .ts { color: var(--muted); white-space: nowrap; }
|
| 75 |
+
.log-entry .comp { color: var(--purple); min-width: 140px; }
|
| 76 |
+
.log-entry .status { font-weight: 600; }
|
| 77 |
+
.log-entry .status.success { color: var(--green); }
|
| 78 |
+
.log-entry .status.failed { color: var(--red); }
|
| 79 |
+
.log-entry .status.started { color: var(--accent); }
|
| 80 |
+
.log-entry .status.stopped { color: var(--orange); }
|
| 81 |
+
|
| 82 |
+
/* Run All bar */
|
| 83 |
+
.run-all-bar { display: flex; gap: 12px; margin-bottom: 24px; }
|
| 84 |
+
.btn-run-all { background: var(--purple); color: #fff; padding: 12px 28px; font-size: 15px; }
|
| 85 |
+
|
| 86 |
+
/* Responsive */
|
| 87 |
+
@media (max-width: 768px) {
|
| 88 |
+
.task-row { grid-template-columns: 1fr; gap: 8px; }
|
| 89 |
+
.task-row.header-row { display: none; }
|
| 90 |
+
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
| 91 |
+
}
|
| 92 |
+
</style>
|
| 93 |
+
</head>
|
| 94 |
+
<body>
|
| 95 |
+
|
| 96 |
+
<div class="header">
|
| 97 |
+
<h1>Masseur<span>Boost</span> Control Panel</h1>
|
| 98 |
+
<nav>
|
| 99 |
+
<a href="/">Home</a>
|
| 100 |
+
<a href="/dashboard">Market Intel</a>
|
| 101 |
+
<a href="/control" class="active">Orchestrator</a>
|
| 102 |
+
</nav>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<div class="container">
|
| 106 |
+
|
| 107 |
+
<!-- Daemon Status -->
|
| 108 |
+
<div class="daemon-bar">
|
| 109 |
+
<div class="daemon-status">
|
| 110 |
+
<div class="daemon-dot" id="daemonDot"></div>
|
| 111 |
+
<div class="daemon-text" id="daemonText">Checking... <small id="daemonSub"></small></div>
|
| 112 |
+
</div>
|
| 113 |
+
<div>
|
| 114 |
+
<button class="btn btn-start" id="daemonStart" onclick="daemonAction('start')">Start Daemon</button>
|
| 115 |
+
<button class="btn btn-stop" id="daemonStop" onclick="daemonAction('stop')" style="display:none">Stop Daemon</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<!-- DB Stats -->
|
| 120 |
+
<div class="section-title">Database Stats</div>
|
| 121 |
+
<div class="stats-grid" id="statsGrid">
|
| 122 |
+
<div class="stat-card"><div class="num">—</div><div class="label">Loading</div></div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<!-- Run All -->
|
| 126 |
+
<div class="run-all-bar">
|
| 127 |
+
<button class="btn btn-run-all" onclick="triggerTask('all')">Run Full Pipeline Now</button>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<!-- Task Schedule -->
|
| 131 |
+
<div class="section-title">Pipeline Tasks <span class="badge" id="taskCount">—</span></div>
|
| 132 |
+
<div class="task-table" id="taskTable">
|
| 133 |
+
<div class="task-row header-row">
|
| 134 |
+
<div>Task</div>
|
| 135 |
+
<div>Interval</div>
|
| 136 |
+
<div>Last Run</div>
|
| 137 |
+
<div>Next In</div>
|
| 138 |
+
<div>Status</div>
|
| 139 |
+
<div>Action</div>
|
| 140 |
+
</div>
|
| 141 |
+
<div id="taskRows">
|
| 142 |
+
<div class="task-row"><div style="color:var(--muted)">Loading tasks...</div></div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<!-- Live Log Feed -->
|
| 147 |
+
<div class="section-title">Live Orchestrator Log</div>
|
| 148 |
+
<div class="log-feed" id="logFeed">
|
| 149 |
+
<div style="color:var(--muted)">Loading logs...</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<script>
|
| 155 |
+
const API = '/api/orchestrator';
|
| 156 |
+
|
| 157 |
+
async function fetchStatus() {
|
| 158 |
+
try {
|
| 159 |
+
const r = await fetch(`${API}/status`);
|
| 160 |
+
const d = await r.json();
|
| 161 |
+
updateDaemon(d.daemon_running);
|
| 162 |
+
renderTasks(d.tasks);
|
| 163 |
+
renderLogs(d.log_tail);
|
| 164 |
+
} catch (e) {
|
| 165 |
+
updateDaemon(false);
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function fetchStats() {
|
| 170 |
+
try {
|
| 171 |
+
const r = await fetch(`${API}/db-stats`);
|
| 172 |
+
const d = await r.json();
|
| 173 |
+
const items = [
|
| 174 |
+
['masseurs_scraped', 'Masseurs Scraped'],
|
| 175 |
+
['cities_scraped', 'Cities'],
|
| 176 |
+
['visitors', 'Visitors Tracked'],
|
| 177 |
+
['visit_log', 'Visit Actions'],
|
| 178 |
+
['hourly_kpis', 'KPI Snapshots'],
|
| 179 |
+
['profile_stats', 'Profile Stats'],
|
| 180 |
+
['bio_experiments', 'Bio Experiments'],
|
| 181 |
+
['blog_posts', 'Blog Posts'],
|
| 182 |
+
['interview_sets', 'Interview Sets'],
|
| 183 |
+
['content_variants', 'Content Variants'],
|
| 184 |
+
['competitor_benchmarks', 'Benchmarks'],
|
| 185 |
+
['receipts', 'Receipts Logged'],
|
| 186 |
+
];
|
| 187 |
+
document.getElementById('statsGrid').innerHTML = items.map(([k, label]) =>
|
| 188 |
+
`<div class="stat-card"><div class="num">${d[k] != null ? d[k].toLocaleString() : '—'}</div><div class="label">${label}</div></div>`
|
| 189 |
+
).join('');
|
| 190 |
+
} catch (e) {}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function updateDaemon(running) {
|
| 194 |
+
const dot = document.getElementById('daemonDot');
|
| 195 |
+
const text = document.getElementById('daemonText');
|
| 196 |
+
const sub = document.getElementById('daemonSub');
|
| 197 |
+
const startBtn = document.getElementById('daemonStart');
|
| 198 |
+
const stopBtn = document.getElementById('daemonStop');
|
| 199 |
+
if (running) {
|
| 200 |
+
dot.classList.add('running');
|
| 201 |
+
text.innerHTML = 'Daemon Running <small>24/7 pipeline active</small>';
|
| 202 |
+
startBtn.style.display = 'none';
|
| 203 |
+
stopBtn.style.display = 'inline-block';
|
| 204 |
+
} else {
|
| 205 |
+
dot.classList.remove('running');
|
| 206 |
+
text.innerHTML = 'Daemon Stopped <small>Click Start to run 24/7</small>';
|
| 207 |
+
startBtn.style.display = 'inline-block';
|
| 208 |
+
stopBtn.style.display = 'none';
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function renderTasks(tasks) {
|
| 213 |
+
document.getElementById('taskCount').textContent = `${tasks.length} tasks`;
|
| 214 |
+
document.getElementById('taskRows').innerHTML = tasks.map(t => {
|
| 215 |
+
const iconClass = t.running ? 'running' : (t.is_due ? 'due' : 'not-due');
|
| 216 |
+
const pillClass = t.running ? 'running' : (t.is_due ? 'due' : 'ok');
|
| 217 |
+
const pillText = t.running ? 'RUNNING' : (t.is_due ? 'DUE' : 'OK');
|
| 218 |
+
const lastRun = t.last_run ? new Date(t.last_run).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : 'Never';
|
| 219 |
+
const nextIn = t.next_in_hours != null ? (t.next_in_hours <= 0 ? 'Now' : `${t.next_in_hours}h`) : '—';
|
| 220 |
+
return `<div class="task-row">
|
| 221 |
+
<div class="task-name"><span class="icon ${iconClass}"></span>${t.label}</div>
|
| 222 |
+
<div>${t.interval_hours}h</div>
|
| 223 |
+
<div style="color:var(--muted);font-size:13px">${lastRun}</div>
|
| 224 |
+
<div>${nextIn}</div>
|
| 225 |
+
<div><span class="status-pill ${pillClass}">${pillText}</span></div>
|
| 226 |
+
<div><button class="btn btn-sm btn-trigger" onclick="triggerTask('${t.task}')" ${t.running ? 'disabled' : ''}>Run</button></div>
|
| 227 |
+
</div>`;
|
| 228 |
+
}).join('');
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
function renderLogs(logs) {
|
| 232 |
+
if (!logs || !logs.length) {
|
| 233 |
+
document.getElementById('logFeed').innerHTML = '<div style="color:var(--muted)">No logs yet</div>';
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
document.getElementById('logFeed').innerHTML = logs.reverse().map(l => {
|
| 237 |
+
const ts = l.timestamp ? new Date(l.timestamp).toLocaleTimeString('en-US', {hour12:false}) : '—';
|
| 238 |
+
const comp = l.component || '—';
|
| 239 |
+
const status = l.status || '';
|
| 240 |
+
const statusClass = status === 'success' ? 'success' : status === 'failed' ? 'failed' : status === 'started' ? 'started' : status === 'stopped' ? 'stopped' : '';
|
| 241 |
+
return `<div class="log-entry"><span class="ts">${ts}</span><span class="comp">${comp}</span><span class="status ${statusClass}">${status}</span></div>`;
|
| 242 |
+
}).join('');
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
async function triggerTask(task) {
|
| 246 |
+
const btn = event?.target;
|
| 247 |
+
if (btn) { btn.disabled = true; btn.textContent = 'Running...'; }
|
| 248 |
+
try {
|
| 249 |
+
await fetch(`${API}/trigger/${task}`, { method: 'POST' });
|
| 250 |
+
setTimeout(() => { fetchStatus(); if (btn) { btn.disabled = false; btn.textContent = 'Run'; } }, 1000);
|
| 251 |
+
} catch (e) {
|
| 252 |
+
if (btn) { btn.disabled = false; btn.textContent = 'Run'; }
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
async function daemonAction(action) {
|
| 257 |
+
try {
|
| 258 |
+
await fetch(`${API}/daemon/${action}`, { method: 'POST' });
|
| 259 |
+
setTimeout(fetchStatus, 2000);
|
| 260 |
+
} catch (e) {}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Auto-refresh
|
| 264 |
+
fetchStatus();
|
| 265 |
+
fetchStats();
|
| 266 |
+
setInterval(fetchStatus, 5000);
|
| 267 |
+
setInterval(fetchStats, 30000);
|
| 268 |
+
</script>
|
| 269 |
+
|
| 270 |
+
</body>
|
| 271 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>MasseurBoost Dashboard</title>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
|
| 10 |
+
rel="stylesheet">
|
| 11 |
+
<style>
|
| 12 |
+
* {
|
| 13 |
+
font-family: 'Inter', sans-serif;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.gradient-bg {
|
| 17 |
+
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.glow {
|
| 21 |
+
box-shadow: 0 0 30px rgba(139, 92, 246, 0.2);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.card {
|
| 25 |
+
background: rgba(255, 255, 255, 0.03);
|
| 26 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 27 |
+
border-radius: 1.5rem;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.card-hover {
|
| 31 |
+
transition: all 0.3s;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.card-hover:hover {
|
| 35 |
+
transform: translateY(-4px);
|
| 36 |
+
border-color: rgba(139, 92, 246, 0.3);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.pulse-dot {
|
| 40 |
+
animation: pulse-dot 2s infinite;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@keyframes pulse-dot {
|
| 44 |
+
|
| 45 |
+
0%,
|
| 46 |
+
100% {
|
| 47 |
+
opacity: 1;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
50% {
|
| 51 |
+
opacity: 0.3;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.tab-active {
|
| 56 |
+
background: linear-gradient(135deg, #8b5cf6, #d946ef);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.progress-bar {
|
| 60 |
+
background: linear-gradient(90deg, #8b5cf6, #d946ef, #ec4899);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.spin {
|
| 64 |
+
animation: spin 2s linear infinite;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
@keyframes spin {
|
| 68 |
+
to {
|
| 69 |
+
transform: rotate(360deg);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
</style>
|
| 73 |
+
</head>
|
| 74 |
+
|
| 75 |
+
<body class="bg-[#0a0a0f] text-white min-h-screen">
|
| 76 |
+
|
| 77 |
+
<!-- Top bar -->
|
| 78 |
+
<div class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
|
| 79 |
+
<div class="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between">
|
| 80 |
+
<div class="flex items-center gap-3">
|
| 81 |
+
<a href="/" class="flex items-center gap-2">
|
| 82 |
+
<div
|
| 83 |
+
class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">
|
| 84 |
+
M</div>
|
| 85 |
+
<span class="font-bold">Masseur<span class="text-violet-400">Boost</span></span>
|
| 86 |
+
</a>
|
| 87 |
+
<span class="text-gray-600">/</span>
|
| 88 |
+
<span class="text-gray-400 text-sm">Dashboard</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="flex items-center gap-4">
|
| 91 |
+
<div class="flex items-center gap-2 text-sm">
|
| 92 |
+
<span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
|
| 93 |
+
<span class="text-gray-400">Engine: Active</span>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="text-sm text-gray-500">Cycle: <span id="cycle-time" class="text-violet-400">3h</span></div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Main -->
|
| 101 |
+
<div class="pt-20 max-w-7xl mx-auto px-6 py-8">
|
| 102 |
+
|
| 103 |
+
<!-- Header -->
|
| 104 |
+
<div class="mb-8">
|
| 105 |
+
<h1 class="text-3xl font-black mb-2">Optimization Dashboard</h1>
|
| 106 |
+
<p class="text-gray-500">Real-time view of your profile's AI optimization engine</p>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<!-- Stats row -->
|
| 110 |
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="stats-row">
|
| 111 |
+
<div class="card p-6">
|
| 112 |
+
<div class="text-sm text-gray-500 mb-1">Market Size</div>
|
| 113 |
+
<div class="text-3xl font-black text-violet-400" id="stat-total">—</div>
|
| 114 |
+
<div class="text-xs text-gray-600 mt-1">masseurs tracked</div>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="card p-6">
|
| 117 |
+
<div class="text-sm text-gray-500 mb-1">Cities</div>
|
| 118 |
+
<div class="text-3xl font-black text-fuchsia-400" id="stat-cities">—</div>
|
| 119 |
+
<div class="text-xs text-gray-600 mt-1">worldwide</div>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="card p-6">
|
| 122 |
+
<div class="text-sm text-gray-500 mb-1">Avg Views/Day</div>
|
| 123 |
+
<div class="text-3xl font-black text-pink-400" id="stat-avgvpd">—</div>
|
| 124 |
+
<div class="text-xs text-gray-600 mt-1">market average</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div class="card p-6">
|
| 127 |
+
<div class="text-sm text-gray-500 mb-1">Gold Members</div>
|
| 128 |
+
<div class="text-3xl font-black text-amber-400" id="stat-gold">—</div>
|
| 129 |
+
<div class="text-xs text-gray-600 mt-1">of total</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<!-- Tabs -->
|
| 134 |
+
<div class="flex gap-2 mb-8 overflow-x-auto">
|
| 135 |
+
<button onclick="switchTab('analyze')" id="tab-analyze"
|
| 136 |
+
class="tab-active px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap">🔍 Profile
|
| 137 |
+
Analysis</button>
|
| 138 |
+
<button onclick="switchTab('bio')" id="tab-bio"
|
| 139 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🧬
|
| 140 |
+
Bio Optimizer</button>
|
| 141 |
+
<button onclick="switchTab('blog')" id="tab-blog"
|
| 142 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">✍️
|
| 143 |
+
Blog Generator</button>
|
| 144 |
+
<button onclick="switchTab('photo')" id="tab-photo"
|
| 145 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">📸
|
| 146 |
+
Photo Rotation</button>
|
| 147 |
+
<button onclick="switchTab('gravity')" id="tab-gravity"
|
| 148 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🌍
|
| 149 |
+
Availability Gravity</button>
|
| 150 |
+
<button onclick="switchTab('engine')" id="tab-engine"
|
| 151 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">⚙️
|
| 152 |
+
Engine Status</button>
|
| 153 |
+
<button onclick="switchTab('search')" id="tab-search"
|
| 154 |
+
class="px-5 py-2.5 rounded-full text-sm font-semibold whitespace-nowrap bg-white/5 border border-white/10 hover:bg-white/10 transition">🔎
|
| 155 |
+
Browse Market</button>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<!-- Tab: Analyze -->
|
| 159 |
+
<div id="panel-analyze" class="tab-panel">
|
| 160 |
+
<div class="card p-8 mb-6">
|
| 161 |
+
<h2 class="text-2xl font-bold mb-4">Analyze Your Profile</h2>
|
| 162 |
+
<p class="text-gray-400 mb-6">Enter your RentMasseur username to see how you rank against all 2,738
|
| 163 |
+
masseurs worldwide.</p>
|
| 164 |
+
<div class="flex gap-3 mb-6">
|
| 165 |
+
<input id="analyze-username" type="text" placeholder="Your RentMasseur username"
|
| 166 |
+
class="flex-1 px-5 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-white">
|
| 167 |
+
<button onclick="analyzeProfile()"
|
| 168 |
+
class="px-6 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold hover:opacity-90 transition">Analyze</button>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
<div id="analyze-results" class="hidden"></div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<!-- Tab: Bio -->
|
| 175 |
+
<div id="panel-bio" class="tab-panel hidden">
|
| 176 |
+
<div class="card p-8 mb-6">
|
| 177 |
+
<h2 class="text-2xl font-bold mb-4">🧬 GA/RL Bio Optimizer</h2>
|
| 178 |
+
<p class="text-gray-400 mb-6">Generate 30 bio variants using Genetic Algorithm + LLM. Each variant is
|
| 179 |
+
scored on CTA strength, urgency, emotional hook, SEO, and uniqueness. The winner gets pushed to your
|
| 180 |
+
live profile.</p>
|
| 181 |
+
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
| 182 |
+
<div>
|
| 183 |
+
<label class="text-sm text-gray-400 mb-2 block">Your username</label>
|
| 184 |
+
<input id="bio-username" type="text" placeholder="e.g. Karpathianwolf"
|
| 185 |
+
class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none">
|
| 186 |
+
</div>
|
| 187 |
+
<div>
|
| 188 |
+
<label class="text-sm text-gray-400 mb-2 block">Bio style</label>
|
| 189 |
+
<select id="bio-style"
|
| 190 |
+
class="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none">
|
| 191 |
+
<option value="balanced">Balanced (recommended)</option>
|
| 192 |
+
<option value="funny">Funny & Engaging</option>
|
| 193 |
+
<option value="luxury">Luxury / Concierge</option>
|
| 194 |
+
<option value="clinical">Clinical / Professional</option>
|
| 195 |
+
<option value="mysterious">Mysterious & Alluring</option>
|
| 196 |
+
</select>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<button onclick="generateBio()"
|
| 200 |
+
class="px-8 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold hover:opacity-90 transition">Generate
|
| 201 |
+
Optimized Bio</button>
|
| 202 |
+
</div>
|
| 203 |
+
<div id="bio-results" class="hidden"></div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<!-- Tab: Blog -->
|
| 207 |
+
<div id="panel-blog" class="tab-panel hidden">
|
| 208 |
+
<div class="card p-8 mb-6">
|
| 209 |
+
<h2 class="text-2xl font-bold mb-4">✍️ LLM Blog Generator</h2>
|
| 210 |
+
<p class="text-gray-400 mb-6">SEO-optimized blog posts generated from winning competitor patterns.
|
| 211 |
+
Funny, engaging, and engineered to drive phone calls and bookings.</p>
|
| 212 |
+
<div id="blog-topics" class="grid md:grid-cols-2 gap-4"></div>
|
| 213 |
+
</div>
|
| 214 |
+
<div id="blog-preview" class="hidden"></div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<!-- Tab: Photo -->
|
| 218 |
+
<div id="panel-photo" class="tab-panel hidden">
|
| 219 |
+
<div class="card p-8">
|
| 220 |
+
<h2 class="text-2xl font-bold mb-4">📸 Organism Photo Rotation</h2>
|
| 221 |
+
<p class="text-gray-400 mb-6">Your photos rotate based on time of day, day of week, and engagement data.
|
| 222 |
+
The profile breathes like a living organism.</p>
|
| 223 |
+
<div id="photo-strategy" class="space-y-4"></div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- Tab: Gravity -->
|
| 228 |
+
<div id="panel-gravity" class="tab-panel hidden">
|
| 229 |
+
<div class="card p-8">
|
| 230 |
+
<h2 class="text-2xl font-bold mb-4">🌍 Availability Gravity Engine</h2>
|
| 231 |
+
<p class="text-gray-400 mb-6">Dynamic availability windows that create urgency and drive repeat visits.
|
| 232 |
+
RL-optimized for maximum booking conversion.</p>
|
| 233 |
+
<div id="gravity-tactics" class="space-y-4"></div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<!-- Tab: Engine -->
|
| 238 |
+
<div id="panel-engine" class="tab-panel hidden">
|
| 239 |
+
<div class="card p-8">
|
| 240 |
+
<h2 class="text-2xl font-bold mb-4">⚙️ Optimization Engine</h2>
|
| 241 |
+
<p class="text-gray-400 mb-6">The full GA + RL + LLM pipeline running 24/7.</p>
|
| 242 |
+
<div id="engine-components" class="space-y-3"></div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<!-- Tab: Search -->
|
| 247 |
+
<div id="panel-search" class="tab-panel hidden">
|
| 248 |
+
<div class="card p-8 mb-6">
|
| 249 |
+
<h2 class="text-2xl font-bold mb-4">🔎 Browse the Market</h2>
|
| 250 |
+
<p class="text-gray-400 mb-6">Search and rank all 2,738 masseurs by views/day, visits, reviews, and
|
| 251 |
+
more.</p>
|
| 252 |
+
<div class="flex flex-wrap gap-3 mb-4">
|
| 253 |
+
<input id="search-city" type="text"
|
| 254 |
+
placeholder="Username or city (e.g. karpathianwolf or manhattan-ny)"
|
| 255 |
+
class="px-4 py-2 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-sm">
|
| 256 |
+
<select id="search-sort"
|
| 257 |
+
class="px-4 py-2 rounded-xl bg-white/5 border border-white/10 focus:border-violet-500 outline-none text-sm">
|
| 258 |
+
<option value="views_per_day">Views/Day</option>
|
| 259 |
+
<option value="visits">Total Visits</option>
|
| 260 |
+
<option value="reviews_count">Reviews</option>
|
| 261 |
+
<option value="rating">Rating</option>
|
| 262 |
+
</select>
|
| 263 |
+
<button onclick="searchMasseurs()"
|
| 264 |
+
class="px-6 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-sm hover:opacity-90 transition">Search</button>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
<div id="search-results"></div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<script>
|
| 272 |
+
// ─── Tab switching ─────────────────────────────────
|
| 273 |
+
function switchTab(name) {
|
| 274 |
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
| 275 |
+
document.getElementById('panel-' + name).classList.remove('hidden');
|
| 276 |
+
document.querySelectorAll('[id^="tab-"]').forEach(t => {
|
| 277 |
+
t.classList.remove('tab-active');
|
| 278 |
+
t.classList.add('bg-white/5', 'border', 'border-white/10');
|
| 279 |
+
});
|
| 280 |
+
const tab = document.getElementById('tab-' + name);
|
| 281 |
+
tab.classList.add('tab-active');
|
| 282 |
+
tab.classList.remove('bg-white/5', 'border', 'border-white/10');
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// ─── Load market stats ─────────────────────────────
|
| 286 |
+
fetch('/api/market-stats').then(r => r.json()).then(d => {
|
| 287 |
+
document.getElementById('stat-total').textContent = d.total_masseurs.toLocaleString();
|
| 288 |
+
document.getElementById('stat-cities').textContent = d.cities_covered;
|
| 289 |
+
document.getElementById('stat-avgvpd').textContent = d.avg_views_per_day;
|
| 290 |
+
document.getElementById('stat-gold').textContent = d.gold_members.toLocaleString();
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
// ─── Analyze profile ───────────────────────────────
|
| 294 |
+
function analyzeProfile() {
|
| 295 |
+
const username = document.getElementById('analyze-username').value.trim();
|
| 296 |
+
if (!username) return;
|
| 297 |
+
const results = document.getElementById('analyze-results');
|
| 298 |
+
results.classList.remove('hidden');
|
| 299 |
+
results.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto mb-4"></div>Analyzing ' + username + '...</div>';
|
| 300 |
+
fetch('/api/analyze/' + username).then(r => r.json()).then(d => {
|
| 301 |
+
if (d.error) {
|
| 302 |
+
results.innerHTML = '<div class="card p-8 text-center text-gray-400">' + d.error + '</div>';
|
| 303 |
+
return;
|
| 304 |
+
}
|
| 305 |
+
const percentile = d.percentile;
|
| 306 |
+
const recs = d.recommendations.map(r => `<li class="flex items-start gap-3 text-gray-300"><span class="text-violet-400 mt-1">→</span> ${r}</li>`).join('');
|
| 307 |
+
results.innerHTML = `
|
| 308 |
+
<div class="grid md:grid-cols-2 gap-6 mb-6">
|
| 309 |
+
<div class="card p-8">
|
| 310 |
+
<div class="text-sm text-gray-500 mb-2">Your Rank</div>
|
| 311 |
+
<div class="text-5xl font-black text-violet-400">#${d.rank}</div>
|
| 312 |
+
<div class="text-gray-500 mt-2">out of ${d.total} masseurs worldwide</div>
|
| 313 |
+
<div class="mt-4">
|
| 314 |
+
<div class="flex justify-between text-sm mb-1"><span class="text-gray-500">Percentile</span><span class="text-violet-400 font-bold">${percentile}%</span></div>
|
| 315 |
+
<div class="h-3 rounded-full bg-white/5 overflow-hidden"><div class="h-full progress-bar" style="width:${percentile}%"></div></div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
<div class="card p-8">
|
| 319 |
+
<div class="grid grid-cols-2 gap-4">
|
| 320 |
+
<div><div class="text-sm text-gray-500">Views/Day</div><div class="text-2xl font-bold text-fuchsia-400">${d.views_per_day}</div></div>
|
| 321 |
+
<div><div class="text-sm text-gray-500">Total Visits</div><div class="text-2xl font-bold text-pink-400">${d.total_visits.toLocaleString()}</div></div>
|
| 322 |
+
<div><div class="text-sm text-gray-500">Member Since</div><div class="text-lg font-bold">${d.member_since}</div></div>
|
| 323 |
+
<div><div class="text-sm text-gray-500">Reviews</div><div class="text-2xl font-bold text-amber-400">${d.reviews_count}</div></div>
|
| 324 |
+
<div><div class="text-sm text-gray-500">Rating</div><div class="text-2xl font-bold text-green-400">${d.rating}★</div></div>
|
| 325 |
+
<div><div class="text-sm text-gray-500">Photos</div><div class="text-2xl font-bold">${d.photo_count}</div></div>
|
| 326 |
+
<div><div class="text-sm text-gray-500">Bio Length</div><div class="text-lg font-bold ${d.bio_length > d.top_avg_bio_length + 200 ? 'text-red-400' : 'text-green-400'}">${d.bio_length} chars</div></div>
|
| 327 |
+
<div><div class="text-sm text-gray-500">Gold</div><div class="text-lg font-bold ${d.is_gold ? 'text-amber-400' : 'text-gray-600'}">${d.is_gold ? 'Yes' : 'No'}</div></div>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
<div class="card p-8 mb-6">
|
| 332 |
+
<h3 class="text-xl font-bold mb-4">Services</h3>
|
| 333 |
+
<div class="flex flex-wrap gap-2">
|
| 334 |
+
${d.services.map(s => `<span class="px-4 py-2 rounded-full bg-violet-500/10 text-violet-300 text-sm">${s}</span>`).join('')}
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
<div class="card p-8">
|
| 338 |
+
<h3 class="text-xl font-bold mb-4">AI Recommendations</h3>
|
| 339 |
+
${recs ? `<ul class="space-y-3">${recs}</ul>` : '<p class="text-green-400">Your profile is well-optimized! No critical issues found.</p>'}
|
| 340 |
+
<div class="mt-6 pt-6 border-t border-white/10">
|
| 341 |
+
<div class="text-sm text-gray-500 mb-2">Top performer benchmark:</div>
|
| 342 |
+
<div class="flex gap-6 text-sm">
|
| 343 |
+
<span>Avg bio length: <strong class="text-violet-400">${d.top_avg_bio_length} chars</strong></span>
|
| 344 |
+
<span>Avg reviews: <strong class="text-violet-400">${d.top_avg_reviews}</strong></span>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
`;
|
| 349 |
+
});
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// ─── Generate bio ──────────────────────────────────
|
| 353 |
+
function generateBio() {
|
| 354 |
+
const username = document.getElementById('bio-username').value.trim();
|
| 355 |
+
const style = document.getElementById('bio-style').value;
|
| 356 |
+
if (!username) return;
|
| 357 |
+
const results = document.getElementById('bio-results');
|
| 358 |
+
results.classList.remove('hidden');
|
| 359 |
+
results.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto mb-4"></div>Generating 30 variants...</div>';
|
| 360 |
+
fetch('/api/generate-bio', {
|
| 361 |
+
method: 'POST',
|
| 362 |
+
headers: { 'Content-Type': 'application/json' },
|
| 363 |
+
body: JSON.stringify({ username, style })
|
| 364 |
+
}).then(r => r.json()).then(d => {
|
| 365 |
+
results.innerHTML = `
|
| 366 |
+
<div class="card p-8 mb-6">
|
| 367 |
+
<h3 class="text-xl font-bold mb-2">Generated Bio — ${style} style</h3>
|
| 368 |
+
<div class="text-xs text-gray-500 mb-4">Analyzed ${d.competitor_patterns_analyzed} top competitor patterns</div>
|
| 369 |
+
<div class="bg-white/5 rounded-xl p-6 border border-white/10">
|
| 370 |
+
<p class="text-gray-300 leading-relaxed">${d.preview}</p>
|
| 371 |
+
</div>
|
| 372 |
+
<div class="mt-6 flex gap-3">
|
| 373 |
+
<button class="px-6 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-sm hover:opacity-90 transition">Push to Profile</button>
|
| 374 |
+
<button onclick="generateBio()" class="px-6 py-2 rounded-xl border border-white/20 font-semibold text-sm hover:bg-white/5 transition">Regenerate</button>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
<div class="card p-8">
|
| 378 |
+
<h3 class="text-lg font-bold mb-4">Top Competitor Patterns Detected</h3>
|
| 379 |
+
<div class="space-y-3">
|
| 380 |
+
${d.top_patterns.map(p => `<div class="flex items-start gap-3"><span class="text-violet-400 text-sm font-bold shrink-0">@${p.username}</span><span class="text-gray-400 text-sm">${p.bio_preview}...</span><span class="text-xs text-gray-600 shrink-0">${p.vpd}/day</span></div>`).join('')}
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
`;
|
| 384 |
+
});
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// ─── Blog topics ───────────────────────────────────
|
| 388 |
+
fetch('/api/blog/topics').then(r => r.json()).then(d => {
|
| 389 |
+
const container = document.getElementById('blog-topics');
|
| 390 |
+
d.topics.forEach(t => {
|
| 391 |
+
container.innerHTML += `
|
| 392 |
+
<div class="card card-hover p-6 cursor-pointer" onclick="this.querySelector('.blog-angle').classList.toggle('hidden')">
|
| 393 |
+
<h4 class="font-bold mb-1">${t.title}</h4>
|
| 394 |
+
<p class="text-sm text-gray-500 blog-angle hidden mt-2">${t.angle}</p>
|
| 395 |
+
<div class="mt-3 text-xs text-violet-400">Click to expand →</div>
|
| 396 |
+
</div>
|
| 397 |
+
`;
|
| 398 |
+
});
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
// ─── Photo strategy ────────────────────────────────
|
| 402 |
+
fetch('/api/photo-strategy').then(r => r.json()).then(d => {
|
| 403 |
+
const container = document.getElementById('photo-strategy');
|
| 404 |
+
container.innerHTML = `
|
| 405 |
+
<div class="mb-6 p-4 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
| 406 |
+
<p class="text-gray-300">${d.description}</p>
|
| 407 |
+
<p class="text-sm text-violet-300 mt-2">Rotation: ${d.rotation_frequency} · AI: ${d.ai_optimization}</p>
|
| 408 |
+
</div>
|
| 409 |
+
${d.schedule.map(s => `
|
| 410 |
+
<div class="flex items-center gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
| 411 |
+
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500/20 to-fuchsia-500/20 flex items-center justify-center text-xl shrink-0">📸</div>
|
| 412 |
+
<div class="flex-1">
|
| 413 |
+
<div class="font-semibold">${s.time}</div>
|
| 414 |
+
<div class="text-sm text-gray-400">${s.type}</div>
|
| 415 |
+
<div class="text-xs text-gray-600 mt-1">${s.reason}</div>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
`).join('')}
|
| 419 |
+
`;
|
| 420 |
+
});
|
| 421 |
+
|
| 422 |
+
// ─── Availability gravity ──────────────────────────
|
| 423 |
+
fetch('/api/availability-gravity').then(r => r.json()).then(d => {
|
| 424 |
+
const container = document.getElementById('gravity-tactics');
|
| 425 |
+
container.innerHTML = `
|
| 426 |
+
<div class="mb-6 p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
| 427 |
+
<p class="text-gray-300">${d.description}</p>
|
| 428 |
+
</div>
|
| 429 |
+
${d.tactics.map(t => `
|
| 430 |
+
<div class="p-5 rounded-xl bg-white/5 border border-white/10">
|
| 431 |
+
<div class="flex items-center justify-between mb-2">
|
| 432 |
+
<h4 class="font-bold text-lg">${t.name}</h4>
|
| 433 |
+
<span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs font-bold">${t.impact}</span>
|
| 434 |
+
</div>
|
| 435 |
+
<p class="text-gray-400 text-sm">${t.description}</p>
|
| 436 |
+
</div>
|
| 437 |
+
`).join('')}
|
| 438 |
+
<div class="mt-4 p-4 rounded-xl bg-gradient-to-r from-violet-500/10 to-fuchsia-500/10 border border-violet-500/20">
|
| 439 |
+
<p class="text-sm text-gray-300">🧠 <strong>RL Optimization:</strong> ${d.rl_optimization}</p>
|
| 440 |
+
</div>
|
| 441 |
+
`;
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
// ─── Engine status ─────────────────────────────────
|
| 445 |
+
fetch('/api/optimization-status').then(r => r.json()).then(d => {
|
| 446 |
+
const container = document.getElementById('engine-components');
|
| 447 |
+
container.innerHTML = `
|
| 448 |
+
<div class="mb-4 p-4 rounded-xl bg-gradient-to-r from-violet-500/10 to-fuchsia-500/10 border border-violet-500/20">
|
| 449 |
+
<p class="text-gray-300"><strong>Engine:</strong> ${d.engine}</p>
|
| 450 |
+
<p class="text-sm text-gray-500 mt-1">${d.cycle}</p>
|
| 451 |
+
<p class="text-sm text-violet-300 mt-1">📊 ${d.data_source}</p>
|
| 452 |
+
</div>
|
| 453 |
+
${d.components.map(c => `
|
| 454 |
+
<div class="flex items-center gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
| 455 |
+
<div class="w-3 h-3 rounded-full bg-green-400 pulse-dot shrink-0"></div>
|
| 456 |
+
<div class="flex-1">
|
| 457 |
+
<div class="font-semibold">${c.name}</div>
|
| 458 |
+
<div class="text-sm text-gray-400">${c.description}</div>
|
| 459 |
+
</div>
|
| 460 |
+
<span class="text-xs text-green-400 font-bold uppercase">${c.status}</span>
|
| 461 |
+
</div>
|
| 462 |
+
`).join('')}
|
| 463 |
+
`;
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
// ─── Search ────────────────────────────────────────
|
| 467 |
+
function searchMasseurs() {
|
| 468 |
+
const city = document.getElementById('search-city').value.trim();
|
| 469 |
+
const sort = document.getElementById('search-sort').value;
|
| 470 |
+
const params = new URLSearchParams({ sort, limit: 50 });
|
| 471 |
+
if (city) params.set('q', city);
|
| 472 |
+
const container = document.getElementById('search-results');
|
| 473 |
+
container.innerHTML = '<div class="card p-8 text-center"><div class="spin w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full mx-auto"></div></div>';
|
| 474 |
+
fetch('/api/search?' + params).then(r => r.json()).then(d => {
|
| 475 |
+
container.innerHTML = `
|
| 476 |
+
<div class="text-sm text-gray-500 mb-4">${d.total} results</div>
|
| 477 |
+
<div class="card overflow-hidden">
|
| 478 |
+
<table class="w-full text-sm">
|
| 479 |
+
<thead class="bg-white/5 text-gray-400">
|
| 480 |
+
<tr>
|
| 481 |
+
<th class="text-left p-3">#</th>
|
| 482 |
+
<th class="text-left p-3">Username</th>
|
| 483 |
+
<th class="text-left p-3">City</th>
|
| 484 |
+
<th class="text-right p-3">Views/Day</th>
|
| 485 |
+
<th class="text-right p-3">Visits</th>
|
| 486 |
+
<th class="text-right p-3">Reviews</th>
|
| 487 |
+
<th class="text-center p-3">Gold</th>
|
| 488 |
+
</tr>
|
| 489 |
+
</thead>
|
| 490 |
+
<tbody>
|
| 491 |
+
${d.results.map((r, i) => `
|
| 492 |
+
<tr class="border-t border-white/5 hover:bg-white/5">
|
| 493 |
+
<td class="p-3 text-gray-500">${i + 1}</td>
|
| 494 |
+
<td class="p-3 font-semibold text-violet-400">
|
| 495 |
+
<a href="/profile/${r.username}" class="hover:underline">@${r.username}</a>
|
| 496 |
+
<a href="${r.profile_url}" target="_blank" class="ml-2 text-xs text-fuchsia-400 hover:text-fuchsia-300">↗ rentmasseur.com/${r.username}</a>
|
| 497 |
+
</td>
|
| 498 |
+
<td class="p-3 text-gray-400">${r.city}</td>
|
| 499 |
+
<td class="p-3 text-right font-bold text-fuchsia-400">${r.vpd}</td>
|
| 500 |
+
<td class="p-3 text-right text-gray-400">${r.visits.toLocaleString()}</td>
|
| 501 |
+
<td class="p-3 text-right text-gray-400">${r.reviews}</td>
|
| 502 |
+
<td class="p-3 text-center">${r.gold ? '⭐' : '—'}</td>
|
| 503 |
+
</tr>
|
| 504 |
+
`).join('')}
|
| 505 |
+
</tbody>
|
| 506 |
+
</table>
|
| 507 |
+
</div>
|
| 508 |
+
`;
|
| 509 |
+
});
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// Auto-load search on tab open
|
| 513 |
+
searchMasseurs();
|
| 514 |
+
</script>
|
| 515 |
+
</body>
|
| 516 |
+
|
| 517 |
+
</html>
|
templates/landing.html
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>MasseurBoost — AI-Powered Profile Optimization for RentMasseur</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
* { font-family: 'Inter', sans-serif; }
|
| 11 |
+
.gradient-bg { background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); }
|
| 12 |
+
.glow { box-shadow: 0 0 40px rgba(139, 92, 246, 0.3); }
|
| 13 |
+
.animate-float { animation: float 6s ease-in-out infinite; }
|
| 14 |
+
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-20px); } }
|
| 15 |
+
.card-hover { transition: all 0.3s ease; }
|
| 16 |
+
.card-hover:hover { transform: translateY(-8px); box-shadow: 0 20px 60px rgba(139,92,246,0.2); }
|
| 17 |
+
.pulse-dot { animation: pulse-dot 2s infinite; }
|
| 18 |
+
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body class="bg-[#0a0a0f] text-white">
|
| 22 |
+
|
| 23 |
+
<!-- Nav -->
|
| 24 |
+
<nav class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
|
| 25 |
+
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 26 |
+
<div class="flex items-center gap-2">
|
| 27 |
+
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center text-xl font-black">M</div>
|
| 28 |
+
<span class="text-xl font-bold">Masseur<span class="text-violet-400">Boost</span></span>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="hidden md:flex items-center gap-8 text-sm text-gray-400">
|
| 31 |
+
<a href="#features" class="hover:text-white transition">Features</a>
|
| 32 |
+
<a href="#how" class="hover:text-white transition">How It Works</a>
|
| 33 |
+
<a href="#pricing" class="hover:text-white transition">Pricing</a>
|
| 34 |
+
<a href="/dashboard" class="hover:text-white transition">Dashboard</a>
|
| 35 |
+
</div>
|
| 36 |
+
<a href="/dashboard" class="px-5 py-2 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 text-sm font-semibold hover:opacity-90 transition">Get Started</a>
|
| 37 |
+
</div>
|
| 38 |
+
</nav>
|
| 39 |
+
|
| 40 |
+
<!-- Hero -->
|
| 41 |
+
<section class="gradient-bg pt-40 pb-32 relative overflow-hidden">
|
| 42 |
+
<div class="absolute inset-0 opacity-20" style="background-image: url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%238b5cf6" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="2"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')"></div>
|
| 43 |
+
<div class="max-w-5xl mx-auto px-6 text-center relative z-10">
|
| 44 |
+
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-violet-500/10 border border-violet-500/30 mb-8">
|
| 45 |
+
<span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
|
| 46 |
+
<span class="text-sm text-violet-300">Now analyzing 2,738 masseurs across 252 cities</span>
|
| 47 |
+
</div>
|
| 48 |
+
<h1 class="text-5xl md:text-7xl font-black leading-tight mb-6">
|
| 49 |
+
Your profile isn't static.<br>
|
| 50 |
+
<span class="bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 bg-clip-text text-transparent">It's a living organism.</span>
|
| 51 |
+
</h1>
|
| 52 |
+
<p class="text-xl text-gray-400 max-w-2xl mx-auto mb-10">
|
| 53 |
+
AI-powered bio optimization, blog generation, photo rotation, and availability gravity —
|
| 54 |
+
all working 24/7 to make your RentMasseur profile attract more clients, more views, more bookings.
|
| 55 |
+
</p>
|
| 56 |
+
<div class="flex items-center justify-center gap-4">
|
| 57 |
+
<a href="/dashboard" class="px-8 py-4 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold text-lg hover:opacity-90 transition glow">Launch Dashboard</a>
|
| 58 |
+
<a href="#features" class="px-8 py-4 rounded-full border border-white/20 font-semibold text-lg hover:bg-white/5 transition">See Features</a>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="mt-16 flex items-center justify-center gap-12 text-center">
|
| 61 |
+
<div>
|
| 62 |
+
<div class="text-4xl font-black text-violet-400">2,738</div>
|
| 63 |
+
<div class="text-sm text-gray-500">Profiles analyzed</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="w-px h-12 bg-white/10"></div>
|
| 66 |
+
<div>
|
| 67 |
+
<div class="text-4xl font-black text-fuchsia-400">252</div>
|
| 68 |
+
<div class="text-sm text-gray-500">Cities worldwide</div>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="w-px h-12 bg-white/10"></div>
|
| 71 |
+
<div>
|
| 72 |
+
<div class="text-4xl font-black text-pink-400">3h</div>
|
| 73 |
+
<div class="text-sm text-gray-500">Optimization cycle</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</section>
|
| 78 |
+
|
| 79 |
+
<!-- Features -->
|
| 80 |
+
<section id="features" class="py-32 bg-[#0a0a0f]">
|
| 81 |
+
<div class="max-w-7xl mx-auto px-6">
|
| 82 |
+
<div class="text-center mb-20">
|
| 83 |
+
<h2 class="text-4xl md:text-5xl font-black mb-4">Everything your profile needs to win</h2>
|
| 84 |
+
<p class="text-xl text-gray-500">Six AI engines working in parallel, 24/7</p>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 87 |
+
|
| 88 |
+
<!-- Bio Optimizer -->
|
| 89 |
+
<div class="card-hover bg-gradient-to-br from-violet-500/10 to-transparent border border-violet-500/20 rounded-3xl p-8">
|
| 90 |
+
<div class="w-14 h-14 rounded-2xl bg-violet-500/20 flex items-center justify-center mb-6 text-2xl">🧬</div>
|
| 91 |
+
<h3 class="text-2xl font-bold mb-3">GA/RL Bio Optimizer</h3>
|
| 92 |
+
<p class="text-gray-400 mb-4">Genetic Algorithm evolves your bio through A/B testing. Reinforcement Learning tracks which versions drive bookings. LLM generates mutations from top-performing competitor patterns.</p>
|
| 93 |
+
<div class="flex flex-wrap gap-2">
|
| 94 |
+
<span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">30 variants tested</span>
|
| 95 |
+
<span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">Head-to-head scoring</span>
|
| 96 |
+
<span class="px-3 py-1 rounded-full bg-violet-500/10 text-violet-300 text-xs">Auto-push winner</span>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Blog Generator -->
|
| 101 |
+
<div class="card-hover bg-gradient-to-br from-fuchsia-500/10 to-transparent border border-fuchsia-500/20 rounded-3xl p-8">
|
| 102 |
+
<div class="w-14 h-14 rounded-2xl bg-fuchsia-500/20 flex items-center justify-center mb-6 text-2xl">✍️</div>
|
| 103 |
+
<h3 class="text-2xl font-bold mb-3">LLM Blog Generator</h3>
|
| 104 |
+
<p class="text-gray-400 mb-4">SEO-optimized blog posts generated from winning competitor patterns. Funny, engaging, and engineered to drive phone calls and bookings. Twice daily, automatically.</p>
|
| 105 |
+
<div class="flex flex-wrap gap-2">
|
| 106 |
+
<span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">SEO keywords</span>
|
| 107 |
+
<span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">Booking CTAs</span>
|
| 108 |
+
<span class="px-3 py-1 rounded-full bg-fuchsia-500/10 text-fuchsia-300 text-xs">Pattern-matched</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- Photo Rotation -->
|
| 113 |
+
<div class="card-hover bg-gradient-to-br from-pink-500/10 to-transparent border border-pink-500/20 rounded-3xl p-8">
|
| 114 |
+
<div class="w-14 h-14 rounded-2xl bg-pink-500/20 flex items-center justify-center mb-6 text-2xl">📸</div>
|
| 115 |
+
<h3 class="text-2xl font-bold mb-3">Organism Photo Swap</h3>
|
| 116 |
+
<p class="text-gray-400 mb-4">Your photos rotate based on time of day, day of week, and engagement data. Morning browsers see professional shots. Evening visitors see lifestyle. Your profile breathes.</p>
|
| 117 |
+
<div class="flex flex-wrap gap-2">
|
| 118 |
+
<span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">Time-of-day AI</span>
|
| 119 |
+
<span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">GA-optimized order</span>
|
| 120 |
+
<span class="px-3 py-1 rounded-full bg-pink-500/10 text-pink-300 text-xs">6h rotation</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<!-- Availability Gravity -->
|
| 125 |
+
<div class="card-hover bg-gradient-to-br from-cyan-500/10 to-transparent border border-cyan-500/20 rounded-3xl p-8">
|
| 126 |
+
<div class="w-14 h-14 rounded-2xl bg-cyan-500/20 flex items-center justify-center mb-6 text-2xl">🌍</div>
|
| 127 |
+
<h3 class="text-2xl font-bold mb-3">Availability Gravity</h3>
|
| 128 |
+
<p class="text-gray-400 mb-4">Dynamic availability windows create urgency and drive repeat visits. "3 slots left this week" converts 40-60% better. RL optimizes which slots to show and when.</p>
|
| 129 |
+
<div class="flex flex-wrap gap-2">
|
| 130 |
+
<span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Scarcity engine</span>
|
| 131 |
+
<span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Peak hour alignment</span>
|
| 132 |
+
<span class="px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-300 text-xs">Revisit triggers</span>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<!-- Interview Rotator -->
|
| 137 |
+
<div class="card-hover bg-gradient-to-br from-amber-500/10 to-transparent border border-amber-500/20 rounded-3xl p-8">
|
| 138 |
+
<div class="w-14 h-14 rounded-2xl bg-amber-500/20 flex items-center justify-center mb-6 text-2xl">🎤</div>
|
| 139 |
+
<h3 class="text-2xl font-bold mb-3">Interview Q&A Engine</h3>
|
| 140 |
+
<p class="text-gray-400 mb-4">Funny, engaging interview Q&A that make clients want to call. LLM generates fresh angles daily — from origin stories to myth-busting to guilty pleasures. Each answer ends with a booking CTA.</p>
|
| 141 |
+
<div class="flex flex-wrap gap-2">
|
| 142 |
+
<span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">20+ angles</span>
|
| 143 |
+
<span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">Humor-driven</span>
|
| 144 |
+
<span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-300 text-xs">Daily rotation</span>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<!-- Price Optimizer -->
|
| 149 |
+
<div class="card-hover bg-gradient-to-br from-emerald-500/10 to-transparent border border-emerald-500/20 rounded-3xl p-8">
|
| 150 |
+
<div class="w-14 h-14 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-6 text-2xl">💰</div>
|
| 151 |
+
<h3 class="text-2xl font-bold mb-3">Price Optimization</h3>
|
| 152 |
+
<p class="text-gray-400 mb-4">GA tests price points against your competitor set to maximize revenue, not just bookings. Find the sweet spot where demand meets profit. RL tracks conversion at each price level.</p>
|
| 153 |
+
<div class="flex flex-wrap gap-2">
|
| 154 |
+
<span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">Revenue-focused</span>
|
| 155 |
+
<span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">Competitor-aware</span>
|
| 156 |
+
<span class="px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-300 text-xs">RL-tracked</span>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</section>
|
| 162 |
+
|
| 163 |
+
<!-- How It Works -->
|
| 164 |
+
<section id="how" class="py-32 bg-gradient-to-b from-[#0a0a0f] to-[#111118]">
|
| 165 |
+
<div class="max-w-5xl mx-auto px-6">
|
| 166 |
+
<div class="text-center mb-20">
|
| 167 |
+
<h2 class="text-4xl md:text-5xl font-black mb-4">How the engine works</h2>
|
| 168 |
+
<p class="text-xl text-gray-500">A continuous loop — scrape, analyze, optimize, push, measure, repeat</p>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="space-y-4">
|
| 171 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 172 |
+
<div class="w-12 h-12 rounded-full bg-violet-500 flex items-center justify-center font-bold text-lg shrink-0">1</div>
|
| 173 |
+
<div>
|
| 174 |
+
<h3 class="text-xl font-bold mb-1">Scrape all competitors</h3>
|
| 175 |
+
<p class="text-gray-400">Search API pulls every masseur in every city. Profile HTML gives us visits, member since, bio, services, photos, reviews. 2,738 profiles and counting.</p>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 179 |
+
<div class="w-12 h-12 rounded-full bg-fuchsia-500 flex items-center justify-center font-bold text-lg shrink-0">2</div>
|
| 180 |
+
<div>
|
| 181 |
+
<h3 class="text-xl font-bold mb-1">Rank by views per day</h3>
|
| 182 |
+
<p class="text-gray-400">Compute views_per_day = total_visits / days_since_registration. Rank all 2,738 masseurs. Identify what top performers do differently.</p>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 186 |
+
<div class="w-12 h-12 rounded-full bg-pink-500 flex items-center justify-center font-bold text-lg shrink-0">3</div>
|
| 187 |
+
<div>
|
| 188 |
+
<h3 class="text-xl font-bold mb-1">Generate 30 variants via LLM</h3>
|
| 189 |
+
<p class="text-gray-400">Groq LLM generates 30 bio variants across 10 strategies, informed by winning patterns. Each scored on CTA strength, urgency, emotional hook, SEO, uniqueness.</p>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 193 |
+
<div class="w-12 h-12 rounded-full bg-cyan-500 flex items-center justify-center font-bold text-lg shrink-0">4</div>
|
| 194 |
+
<div>
|
| 195 |
+
<h3 class="text-xl font-bold mb-1">A/B test head-to-head</h3>
|
| 196 |
+
<p class="text-gray-400">Pairwise LLM comparison eliminates weak variants. Top 3 face off. Winner must beat current live bio to qualify for push.</p>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 200 |
+
<div class="w-12 h-12 rounded-full bg-amber-500 flex items-center justify-center font-bold text-lg shrink-0">5</div>
|
| 201 |
+
<div>
|
| 202 |
+
<h3 class="text-xl font-bold mb-1">Push to live profile</h3>
|
| 203 |
+
<p class="text-gray-400">Winning bio, blog post, interview set, and photo order pushed via RentMasseur API. Receipts written for audit trail.</p>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="flex items-center gap-6 bg-white/5 rounded-2xl p-6 border border-white/10">
|
| 207 |
+
<div class="w-12 h-12 rounded-full bg-emerald-500 flex items-center justify-center font-bold text-lg shrink-0">6</div>
|
| 208 |
+
<div>
|
| 209 |
+
<h3 class="text-xl font-bold mb-1">RL feedback loop</h3>
|
| 210 |
+
<p class="text-gray-400">Scrape dashboard stats: views, phone clicks, emails, bookings. Calculate reward. If engagement drops, trigger rotation. Repeat every 3 hours.</p>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</section>
|
| 216 |
+
|
| 217 |
+
<!-- Pricing -->
|
| 218 |
+
<section id="pricing" class="py-32 bg-[#0a0a0f]">
|
| 219 |
+
<div class="max-w-6xl mx-auto px-6">
|
| 220 |
+
<div class="text-center mb-20">
|
| 221 |
+
<h2 class="text-4xl md:text-5xl font-black mb-4">Pricing that scales with you</h2>
|
| 222 |
+
<p class="text-xl text-gray-500">From first optimization to full autonomy</p>
|
| 223 |
+
</div>
|
| 224 |
+
<div class="grid md:grid-cols-3 gap-8" id="pricing-cards">
|
| 225 |
+
<!-- Filled by JS -->
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</section>
|
| 229 |
+
|
| 230 |
+
<!-- CTA -->
|
| 231 |
+
<section class="py-32 gradient-bg">
|
| 232 |
+
<div class="max-w-3xl mx-auto px-6 text-center">
|
| 233 |
+
<h2 class="text-4xl md:text-5xl font-black mb-6">Your competitors are already optimizing.</h2>
|
| 234 |
+
<p class="text-xl text-gray-400 mb-10">While you're reading this, top performers are running GA loops, testing bios, and rotating photos. Don't get left behind.</p>
|
| 235 |
+
<a href="/dashboard" class="inline-block px-10 py-5 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 font-bold text-xl hover:opacity-90 transition glow">Launch Your Dashboard →</a>
|
| 236 |
+
</div>
|
| 237 |
+
</section>
|
| 238 |
+
|
| 239 |
+
<!-- Footer -->
|
| 240 |
+
<footer class="py-12 bg-[#0a0a0f] border-t border-white/5">
|
| 241 |
+
<div class="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
|
| 242 |
+
<div class="flex items-center gap-2">
|
| 243 |
+
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">M</div>
|
| 244 |
+
<span class="font-bold">MasseurBoost</span>
|
| 245 |
+
</div>
|
| 246 |
+
<p class="text-sm text-gray-600">AI-powered profile optimization · 2,738 profiles · 252 cities · GA + RL + LLM</p>
|
| 247 |
+
</div>
|
| 248 |
+
</footer>
|
| 249 |
+
|
| 250 |
+
<script>
|
| 251 |
+
// Load pricing
|
| 252 |
+
fetch('/api/pricing').then(r => r.json()).then(data => {
|
| 253 |
+
const container = document.getElementById('pricing-cards');
|
| 254 |
+
data.plans.forEach(plan => {
|
| 255 |
+
const popular = plan.popular ? 'border-violet-500 glow' : 'border-white/10';
|
| 256 |
+
const badge = plan.popular ? '<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 text-xs font-bold">MOST POPULAR</div>' : '';
|
| 257 |
+
container.innerHTML += `
|
| 258 |
+
<div class="relative bg-white/5 ${popular} border rounded-3xl p-8 card-hover">
|
| 259 |
+
${badge}
|
| 260 |
+
<h3 class="text-2xl font-bold mb-2">${plan.name}</h3>
|
| 261 |
+
<div class="text-4xl font-black mb-6">${plan.price}</div>
|
| 262 |
+
<ul class="space-y-3">
|
| 263 |
+
${plan.features.map(f => `<li class="flex items-start gap-3 text-gray-400"><span class="text-violet-400 mt-1">✓</span> ${f}</li>`).join('')}
|
| 264 |
+
</ul>
|
| 265 |
+
<a href="/dashboard" class="block mt-8 py-3 rounded-full text-center ${plan.popular ? 'bg-gradient-to-r from-violet-500 to-fuchsia-500' : 'border border-white/20 hover:bg-white/5'} font-semibold transition">Get Started</a>
|
| 266 |
+
</div>
|
| 267 |
+
`;
|
| 268 |
+
});
|
| 269 |
+
});
|
| 270 |
+
</script>
|
| 271 |
+
</body>
|
| 272 |
+
</html>
|
templates/profile.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>@{{ profile.username }} — MasseurBoost Profile</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
* { font-family: 'Inter', sans-serif; }
|
| 11 |
+
.gradient-bg { background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%); }
|
| 12 |
+
.card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 1.5rem; }
|
| 13 |
+
.progress-bar { background: linear-gradient(90deg, #8b5cf6, #d946ef, #ec4899); }
|
| 14 |
+
.pulse-dot { animation: pulse-dot 2s infinite; }
|
| 15 |
+
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body class="bg-[#0a0a0f] text-white min-h-screen">
|
| 19 |
+
|
| 20 |
+
<!-- Top bar -->
|
| 21 |
+
<div class="fixed top-0 w-full z-50 bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-white/5">
|
| 22 |
+
<div class="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
| 23 |
+
<div class="flex items-center gap-3">
|
| 24 |
+
<a href="/dashboard" class="flex items-center gap-2">
|
| 25 |
+
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center font-black">M</div>
|
| 26 |
+
<span class="font-bold">Masseur<span class="text-violet-400">Boost</span></span>
|
| 27 |
+
</a>
|
| 28 |
+
<span class="text-gray-600">/</span>
|
| 29 |
+
<span class="text-gray-400 text-sm">Profile</span>
|
| 30 |
+
</div>
|
| 31 |
+
<a href="{{ rentmasseur_url }}" target="_blank" class="text-sm text-violet-400 hover:text-violet-300 transition">View on RentMasseur ↗</a>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
{% if error %}
|
| 36 |
+
<div class="pt-24 max-w-2xl mx-auto px-6 text-center">
|
| 37 |
+
<h1 class="text-3xl font-black mb-4">Profile not found</h1>
|
| 38 |
+
<p class="text-gray-500">@{{ username }} is not in the database (3,374+ profiles scraped).</p>
|
| 39 |
+
<a href="/dashboard" class="inline-block mt-6 px-6 py-3 rounded-xl bg-gradient-to-r from-violet-500 to-fuchsia-500 font-semibold">← Back to Dashboard</a>
|
| 40 |
+
</div>
|
| 41 |
+
{% else %}
|
| 42 |
+
|
| 43 |
+
<div class="pt-20 max-w-5xl mx-auto px-6 py-8">
|
| 44 |
+
|
| 45 |
+
<!-- Profile header -->
|
| 46 |
+
<div class="card p-8 mb-6">
|
| 47 |
+
<div class="flex items-start justify-between flex-wrap gap-4">
|
| 48 |
+
<div>
|
| 49 |
+
<div class="flex items-center gap-3 mb-2">
|
| 50 |
+
<h1 class="text-3xl font-black">@{{ profile.username }}</h1>
|
| 51 |
+
{% if profile.is_gold %}<span class="px-3 py-1 rounded-full bg-amber-500/10 text-amber-400 text-xs font-bold">⭐ GOLD</span>{% endif %}
|
| 52 |
+
{% if profile.is_online %}<span class="px-3 py-1 rounded-full bg-green-500/10 text-green-400 text-xs font-bold pulse-dot">● ONLINE</span>{% endif %}
|
| 53 |
+
</div>
|
| 54 |
+
<p class="text-gray-400">{{ profile.location or profile.city }} · Member since {{ profile.member_since or '?' }}</p>
|
| 55 |
+
{% if profile.headline %}<p class="text-violet-300 mt-2 text-lg">{{ profile.headline }}</p>{% endif %}
|
| 56 |
+
</div>
|
| 57 |
+
<div class="text-right">
|
| 58 |
+
<div class="text-sm text-gray-500">Global Rank</div>
|
| 59 |
+
<div class="text-4xl font-black text-violet-400">#{{ rank }}</div>
|
| 60 |
+
<div class="text-xs text-gray-600">of {{ total }} masseurs</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<!-- Stats grid -->
|
| 66 |
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
| 67 |
+
<div class="card p-6">
|
| 68 |
+
<div class="text-sm text-gray-500 mb-1">Views/Day</div>
|
| 69 |
+
<div class="text-3xl font-black text-fuchsia-400">{{ profile.views_per_day }}</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="card p-6">
|
| 72 |
+
<div class="text-sm text-gray-500 mb-1">Total Visits</div>
|
| 73 |
+
<div class="text-3xl font-black text-pink-400">{{ "{:,}".format(profile.visits) }}</div>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="card p-6">
|
| 76 |
+
<div class="text-sm text-gray-500 mb-1">Rating</div>
|
| 77 |
+
<div class="text-3xl font-black text-green-400">{{ profile.rating }}★</div>
|
| 78 |
+
<div class="text-xs text-gray-600 mt-1">{{ profile.reviews_count }} reviews</div>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="card p-6">
|
| 81 |
+
<div class="text-sm text-gray-500 mb-1">Experience</div>
|
| 82 |
+
<div class="text-3xl font-black text-amber-400">{{ profile.experience_years }}y</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<!-- Percentile bar -->
|
| 87 |
+
<div class="card p-6 mb-6">
|
| 88 |
+
<div class="flex justify-between text-sm mb-2">
|
| 89 |
+
<span class="text-gray-500">Percentile Ranking</span>
|
| 90 |
+
<span class="text-violet-400 font-bold">Top {{ (100 - percentile) }}% ({{ percentile }}th percentile)</span>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="h-4 rounded-full bg-white/5 overflow-hidden">
|
| 93 |
+
<div class="h-full progress-bar" style="width:{{ percentile }}%"></div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- Bio -->
|
| 98 |
+
{% if profile.bio %}
|
| 99 |
+
<div class="card p-8 mb-6">
|
| 100 |
+
<h2 class="text-xl font-bold mb-4">Bio</h2>
|
| 101 |
+
<div class="bg-white/5 rounded-xl p-6 border border-white/10">
|
| 102 |
+
<p class="text-gray-300 leading-relaxed whitespace-pre-wrap">{{ profile.bio[:3000] }}</p>
|
| 103 |
+
</div>
|
| 104 |
+
<div class="text-xs text-gray-600 mt-3">Bio length: {{ profile.bio|length }} chars · Content hash: {{ profile.thumbnail or 'n/a' }}</div>
|
| 105 |
+
</div>
|
| 106 |
+
{% endif %}
|
| 107 |
+
|
| 108 |
+
<!-- Services -->
|
| 109 |
+
{% if services %}
|
| 110 |
+
<div class="card p-8 mb-6">
|
| 111 |
+
<h2 class="text-xl font-bold mb-4">Services</h2>
|
| 112 |
+
<div class="flex flex-wrap gap-2">
|
| 113 |
+
{% for s in services %}
|
| 114 |
+
<span class="px-4 py-2 rounded-full bg-violet-500/10 text-violet-300 text-sm">{{ s }}</span>
|
| 115 |
+
{% endfor %}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
{% endif %}
|
| 119 |
+
|
| 120 |
+
<!-- Details grid -->
|
| 121 |
+
<div class="card p-8 mb-6">
|
| 122 |
+
<h2 class="text-xl font-bold mb-4">Profile Details</h2>
|
| 123 |
+
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
| 124 |
+
<div><span class="text-gray-500">Username:</span> <span class="font-semibold">@{{ profile.username }}</span></div>
|
| 125 |
+
<div><span class="text-gray-500">User ID:</span> <span class="font-semibold">{{ profile.user_id or 'N/A' }}</span></div>
|
| 126 |
+
<div><span class="text-gray-500">City:</span> <span class="font-semibold">{{ profile.city }}</span></div>
|
| 127 |
+
<div><span class="text-gray-500">Location:</span> <span class="font-semibold">{{ profile.location or 'N/A' }}</span></div>
|
| 128 |
+
<div><span class="text-gray-500">Member Since:</span> <span class="font-semibold">{{ profile.member_since or 'N/A' }}</span></div>
|
| 129 |
+
<div><span class="text-gray-500">Last Login:</span> <span class="font-semibold">{{ profile.last_login or 'N/A' }}</span></div>
|
| 130 |
+
<div><span class="text-gray-500">Photo Count:</span> <span class="font-semibold">{{ profile.photo_count }}</span></div>
|
| 131 |
+
<div><span class="text-gray-500">Gold Member:</span> <span class="font-semibold">{{ 'Yes' if profile.is_gold else 'No' }}</span></div>
|
| 132 |
+
<div><span class="text-gray-500">Online:</span> <span class="font-semibold">{{ 'Yes' if profile.is_online else 'No' }}</span></div>
|
| 133 |
+
<div><span class="text-gray-500">Scraped At:</span> <span class="font-semibold">{{ profile.scraped_at or 'N/A' }}</span></div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<!-- City peers -->
|
| 138 |
+
{% if city_peers %}
|
| 139 |
+
<div class="card p-8 mb-6">
|
| 140 |
+
<h2 class="text-xl font-bold mb-4">Top Peers in {{ profile.city }}</h2>
|
| 141 |
+
<div class="space-y-2">
|
| 142 |
+
{% for peer in city_peers %}
|
| 143 |
+
<a href="/profile/{{ peer.username }}" class="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition">
|
| 144 |
+
<span class="font-semibold text-violet-400">@{{ peer.username }}</span>
|
| 145 |
+
<div class="flex gap-6 text-sm">
|
| 146 |
+
<span class="text-fuchsia-400 font-bold">{{ peer.views_per_day }}/day</span>
|
| 147 |
+
<span class="text-gray-400">{{ "{:,}".format(peer.visits) }} visits</span>
|
| 148 |
+
</div>
|
| 149 |
+
</a>
|
| 150 |
+
{% endfor %}
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
{% endif %}
|
| 154 |
+
|
| 155 |
+
<!-- Proof footer -->
|
| 156 |
+
<div class="card p-6 mb-6 border-violet-500/20">
|
| 157 |
+
<div class="flex items-center gap-3 text-sm text-gray-400">
|
| 158 |
+
<span class="w-2 h-2 rounded-full bg-green-400 pulse-dot"></span>
|
| 159 |
+
<span>Scraped from <a href="{{ rentmasseur_url }}" target="_blank" class="text-violet-400 hover:underline">rentmasseur.com/{{ profile.username }}</a> on {{ profile.scraped_at or 'unknown date' }}</span>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<a href="/dashboard" class="inline-block mb-8 text-violet-400 hover:text-violet-300 transition">← Back to Dashboard</a>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{% endif %}
|
| 167 |
+
</body>
|
| 168 |
+
</html>
|