mirror of
https://github.com/myronblair/tomtomgames-app
synced 2026-06-30 17:49:57 -05:00
3192 lines
194 KiB
PHP
3192 lines
194 KiB
PHP
<?php
|
||
ob_start();
|
||
require_once __DIR__ . '/../../includes/auth.php';
|
||
// Redirect to admin login if not logged in or not admin
|
||
if (!isLoggedIn() || empty($_SESSION['is_admin'])) {
|
||
ob_end_clean();
|
||
header('Location: /admin/login.php'); exit;
|
||
}
|
||
ob_end_clean();
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||
<meta name="googlebot" content="noindex, nofollow">
|
||
<title>TomTomGames Admin</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700;900&family=Rajdhani:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
/* ── Typography Scale ── */
|
||
--fs-xs:11px;--fs-sm:13px;--fs-base:15px;--fs-md:16px;
|
||
--fs-lg:18px;--fs-xl:20px;--fs-2xl:24px;--lh:1.6;--bg:#0a0a12;--bg2:#10101e;--bg3:#181828;--card:#1a1a2e;--border:rgba(255,255,255,0.08);--gold:#f0c040;--gold2:#ffdc73;--cyan:#00e5ff;--purple:#9b5de5;--green:#00e676;--red:#ff4444;--yellow:#ffd60a;--text:#e8e8f0;--text2:#8888aa;--r:12px;--rs:8px}
|
||
body{background:var(--bg);color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;min-height:100vh;display:flex}
|
||
aside{width:210px;min-height:100vh;background:var(--bg2);border-right:1px solid var(--border);padding:0;display:flex;flex-direction:column;position:fixed;left:0;top:0;bottom:0}
|
||
.sidebar-head{padding:20px 18px 16px;border-bottom:1px solid var(--border)}
|
||
.sidebar-logo{font-family:'Exo 2',sans-serif;font-weight:900;font-size:15px;display:flex;align-items:center;gap:8px}.sidebar-logo span{background:linear-gradient(135deg,var(--gold),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.sidebar-sub{font-size:12px;color:var(--text2);letter-spacing:1px;margin-top:2px;font-weight:700}
|
||
.nav-section{padding:12px 12px 4px;font-size:12px;font-weight:700;color:var(--text2);letter-spacing:1.5px;text-transform:uppercase}
|
||
.nav-item{display:flex;align-items:center;gap:9px;padding:10px 16px;color:var(--text2);cursor:pointer;transition:all .15s;font-weight:600;font-size:15px;border:none;background:none;width:100%;text-align:left;border-left:3px solid transparent}
|
||
.nav-item:hover{color:var(--text);background:rgba(255,255,255,.03)}
|
||
.nav-item.active{color:var(--gold);border-left-color:var(--gold);background:rgba(240,192,64,.05)}
|
||
.nav-item .badge-count{margin-left:auto;background:var(--red);color:#fff;font-size:12px;font-weight:700;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
||
.sidebar-footer{margin-top:auto;padding:12px}
|
||
.logout-btn{width:100%;padding:10px;border:1px solid rgba(255,68,68,.3);border-radius:var(--rs);background:rgba(255,68,68,.06);color:var(--red);cursor:pointer;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;letter-spacing:.5px}
|
||
|
||
main{margin-left:210px;flex:1;padding:28px 32px;min-height:100vh}
|
||
.page-title{font-family:'Exo 2',sans-serif;font-weight:900;font-size:26px;margin-bottom:20px;background:linear-gradient(135deg,var(--gold),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||
.section{display:none}
|
||
.section.active{display:block}
|
||
|
||
/* STATS */
|
||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:28px}
|
||
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px 16px}
|
||
.stat-label{font-size:12px;font-weight:700;color:var(--text2);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:6px}
|
||
.stat-value{font-family:'Exo 2',sans-serif;font-weight:900;font-size:30px;color:var(--gold);line-height:1}
|
||
.stat-sub{font-size:13px;color:var(--text2);margin-top:4px}
|
||
|
||
/* CARD */
|
||
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px;margin-bottom:18px}
|
||
.card-title{font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;margin-bottom:14px;color:var(--text);display:flex;align-items:center;gap:8px}
|
||
|
||
/* TABLE */
|
||
.tbl-wrap{overflow-x:auto}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{text-align:left;font-size:12px;font-weight:700;color:var(--text2);letter-spacing:1px;text-transform:uppercase;padding:0 10px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
td{padding:10px;border-bottom:1px solid rgba(255,255,255,.03);font-size:15px;vertical-align:middle}
|
||
tr:last-child td{border-bottom:none}
|
||
tr:hover td{background:rgba(255,255,255,.015)}
|
||
|
||
/* BADGES */
|
||
.badge{display:inline-block;font-size:12px;font-weight:700;padding:2px 8px;border-radius:20px;letter-spacing:.3px;white-space:nowrap}
|
||
.b-pending {background:rgba(255,214,10,.15);color:var(--yellow)}
|
||
.b-approved,.b-completed{background:rgba(0,230,118,.15);color:var(--green)}
|
||
.b-rejected,.b-failed {background:rgba(255,68,68,.15);color:var(--red)}
|
||
.b-active {background:rgba(0,230,118,.15);color:var(--green)}
|
||
.b-suspended{background:rgba(255,68,68,.15);color:var(--red)}
|
||
.b-card {background:rgba(0,229,255,.12);color:var(--cyan)}
|
||
.b-venmo {background:rgba(59,89,200,.2);color:#7b9fff}
|
||
.b-chime {background:rgba(0,200,100,.12);color:#00e676}
|
||
.b-cashapp {background:rgba(0,180,50,.12);color:#4caf50}
|
||
.b-zelle {background:rgba(160,32,240,.15);color:#c77dff}
|
||
|
||
/* BUTTONS */
|
||
.btn{padding:7px 13px;border:none;border-radius:var(--rs);font-family:'Exo 2',sans-serif;font-weight:700;font-size:13px;letter-spacing:.5px;cursor:pointer;transition:all .15s;text-transform:uppercase;white-space:nowrap}
|
||
.btn:active{transform:scale(.97)}
|
||
.btn-green{background:rgba(0,230,118,.12);color:var(--green);border:1px solid rgba(0,230,118,.3)}
|
||
.btn-green:hover{background:rgba(0,230,118,.22)}
|
||
.btn-red{background:rgba(255,68,68,.12);color:var(--red);border:1px solid rgba(255,68,68,.3)}
|
||
.btn-red:hover{background:rgba(255,68,68,.22)}
|
||
.btn-gold{background:rgba(240,192,64,.12);color:var(--gold);border:1px solid rgba(240,192,64,.3)}
|
||
.btn-gold:hover{background:rgba(240,192,64,.22)}
|
||
.btn-cyan{background:rgba(0,229,255,.1);color:var(--cyan);border:1px solid rgba(0,229,255,.3)}
|
||
|
||
/* FILTER TABS */
|
||
.filter-bar{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap}
|
||
.ftab{padding:6px 14px;border:1px solid var(--border);border-radius:20px;background:none;color:var(--text2);cursor:pointer;font-size:14px;font-weight:600;transition:all .15s}
|
||
.ftab.active{border-color:var(--gold);color:var(--gold);background:rgba(240,192,64,.06)}
|
||
|
||
/* FORM inline */
|
||
.fi-sm{background:var(--bg3);border:1px solid var(--border);border-radius:var(--rs);padding:7px 10px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;outline:none}
|
||
.fi-sm:focus{border-color:var(--cyan)}
|
||
|
||
/* SEARCH */
|
||
.search-row{margin-bottom:14px;display:flex;gap:10px;align-items:center}
|
||
.search-row input{background:var(--bg3);border:1px solid var(--border);border-radius:var(--rs);padding:9px 13px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;outline:none;width:260px}
|
||
.search-row input:focus{border-color:var(--cyan)}
|
||
|
||
/* PURCHASE CARD (milkyswipe style) */
|
||
.purchase-row{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:14px;margin-bottom:10px}
|
||
.purchase-row.urgent{border-color:rgba(255,214,10,.3);background:rgba(255,214,10,.03)}
|
||
.pr-top{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:10px}
|
||
.pr-info{}
|
||
.pr-user{font-weight:700;font-size:15px;color:var(--text)}
|
||
.pr-ref{font-size:13px;color:var(--text2);margin-top:1px}
|
||
.pr-amount{text-align:right}
|
||
.pr-tokens{font-family:'Exo 2',sans-serif;font-weight:900;font-size:22px;color:var(--gold);line-height:1}
|
||
.pr-dollars{font-size:14px;color:var(--green);font-weight:700}
|
||
.pr-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
|
||
.pr-actions{display:flex;gap:8px;align-items:center}
|
||
.pr-note{flex:1}
|
||
.pr-note input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:var(--rs);padding:7px 10px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;outline:none}
|
||
.pr-note input:focus{border-color:var(--cyan)}
|
||
|
||
/* TOAST */
|
||
#toast{position:fixed;top:18px;right:18px;padding:11px 18px;border-radius:var(--rs);font-weight:700;font-size:15px;z-index:999;display:none;box-shadow:0 4px 20px rgba(0,0,0,.4)}
|
||
#toast.show{display:block}
|
||
#toast.ok{background:#00e676;color:#000}
|
||
#toast.err{background:#ff4444;color:#fff}
|
||
#toast.info{background:var(--card);color:var(--cyan);border:1px solid rgba(0,229,255,.3)}
|
||
|
||
.empty{text-align:center;padding:36px;color:var(--text2);font-size:15px}
|
||
|
||
/* ─── ADMIN SAVED CARD ──────────────────────────────── */
|
||
.admin-saved-card{background:linear-gradient(135deg,#1c1c3a,#2d1b69);border-radius:14px;padding:18px;position:relative;overflow:hidden;border:1px solid rgba(255,255,255,.1)}
|
||
.admin-saved-card::before{content:'';position:absolute;top:-30px;right:-30px;width:110px;height:110px;border-radius:50%;background:rgba(255,255,255,.04)}
|
||
|
||
/* ─── GAME MANAGEMENT ────────────────────────────────── */
|
||
.game-row{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--border);transition:background .15s}
|
||
.game-row:last-child{border-bottom:none}
|
||
.game-row:hover{background:rgba(255,255,255,.02)}
|
||
.game-color-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;box-shadow:0 0 8px currentColor}
|
||
.game-info{flex:1;min-width:0}
|
||
.game-name{font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text)}
|
||
.game-slug{font-size:13px;color:var(--text2);font-family:monospace;margin-top:1px}
|
||
.game-urls{font-size:13px;color:var(--text2);margin-top:3px;display:flex;flex-direction:column;gap:1px}
|
||
.game-url-row{display:flex;align-items:center;gap:5px;overflow:hidden}
|
||
.game-url-label{font-weight:700;color:var(--cyan);flex-shrink:0;font-size:12px;letter-spacing:.5px}
|
||
.game-url-val{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text2)}
|
||
.game-url-link{color:var(--cyan);text-decoration:none;font-size:12px;flex-shrink:0}
|
||
.game-url-link:hover{text-decoration:underline}
|
||
.game-actions{display:flex;gap:6px;flex-shrink:0}
|
||
.game-edit-btn{padding:6px 12px;border-radius:6px;border:none;cursor:pointer;font-family:'Exo 2',sans-serif;font-weight:700;font-size:13px;transition:all .15s}
|
||
.inactive-overlay{opacity:.5}
|
||
|
||
/* ─── ADMIN CHAT ─────────────────────────────────── */
|
||
.chat-inbox-row{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s}
|
||
.chat-inbox-row:last-child{border-bottom:none}
|
||
.chat-inbox-row:hover{background:rgba(255,255,255,.03)}
|
||
.chat-inbox-row.unread{background:rgba(240,192,64,.04)}
|
||
.ci-avatar{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,var(--purple),var(--cyan));display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:900;font-size:18px;color:#fff;flex-shrink:0}
|
||
.ci-body{flex:1;min-width:0}
|
||
.ci-top{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||
.ci-name{font-weight:700;font-size:15px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.ci-time{font-size:13px;color:var(--text2);white-space:nowrap;flex-shrink:0}
|
||
.ci-preview{font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
||
.ci-preview.unread-preview{color:var(--text);font-weight:600}
|
||
.ci-badge{background:var(--red);color:#fff;font-size:12px;font-weight:700;min-width:18px;height:18px;border-radius:9px;display:flex;align-items:center;justify-content:center;padding:0 4px;flex-shrink:0}
|
||
.admin-chat-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column;height:calc(100vh - 180px)}
|
||
.admin-chat-header{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0}
|
||
.admin-chat-avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--purple),var(--cyan));display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:900;font-size:17px;color:#fff;flex-shrink:0}
|
||
.admin-chat-name{font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text)}
|
||
.admin-chat-meta{font-size:14px;color:var(--text2);margin-top:2px}
|
||
.admin-chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:8px}
|
||
.admin-bubble-wrap{display:flex;flex-direction:column;max-width:75%}
|
||
.admin-bubble-wrap.from-user{align-self:flex-start;align-items:flex-start}
|
||
.admin-bubble-wrap.from-admin{align-self:flex-end;align-items:flex-end}
|
||
.admin-bubble{padding:9px 14px;border-radius:18px;font-size:15px;line-height:1.45;word-break:break-word}
|
||
.admin-bubble.from-user{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px}
|
||
.admin-bubble.from-admin{background:linear-gradient(135deg,var(--gold),#d4a017);color:#000;font-weight:500;border-bottom-right-radius:4px}
|
||
.admin-bubble-time{font-size:12px;color:var(--text2);margin-top:3px;padding:0 4px}
|
||
.admin-chat-input-bar{display:flex;gap:8px;padding:12px 14px;border-top:1px solid var(--border);background:var(--bg2);flex-shrink:0}
|
||
.admin-chat-input{flex:1;background:var(--bg3);border:1.5px solid var(--border);border-radius:24px;padding:9px 16px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;outline:none;transition:border-color .15s}
|
||
.admin-chat-input:focus{border-color:var(--cyan)}
|
||
.admin-send-btn{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,var(--gold),#d4a017);border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#000}
|
||
.admin-send-btn svg{stroke:#000}
|
||
.admin-date-div{text-align:center;font-size:13px;color:var(--text2);font-weight:700;margin:8px 0;display:flex;align-items:center;gap:8px}
|
||
.admin-date-div::before,.admin-date-div::after{content:'';flex:1;height:1px;background:var(--border)}
|
||
|
||
/* ─── GAMER MANAGEMENT ───────────────────────────────── */
|
||
.gm-toolbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}
|
||
.gm-search-wrap{position:relative;flex:1;min-width:200px}
|
||
.gm-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--text2)}
|
||
.gm-search{width:100%;background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:10px 14px 10px 36px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:16px;line-height:1.6;outline:none}
|
||
.gm-search:focus{border-color:var(--cyan)}
|
||
.gm-filters{display:flex;gap:6px;flex-shrink:0}
|
||
.gm-filter{padding:7px 14px;border:1px solid var(--border);border-radius:20px;background:none;color:var(--text2);cursor:pointer;font-size:14px;font-weight:600;transition:all .15s;white-space:nowrap}
|
||
.gm-filter.active{border-color:var(--gold);color:var(--gold);background:rgba(240,192,64,.07)}
|
||
.gm-stats-mini{font-size:14px;color:var(--text2);white-space:nowrap}
|
||
|
||
.gm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
|
||
.gm-card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px;cursor:pointer;transition:all .18s;position:relative;overflow:hidden}
|
||
.gm-card:hover{border-color:rgba(240,192,64,.3);transform:translateY(-2px);box-shadow:0 6px 24px rgba(0,0,0,.3)}
|
||
.gm-card.suspended{border-color:rgba(255,68,68,.2);background:rgba(255,30,30,.03)}
|
||
.gm-card.unverified{border-color:rgba(255,214,10,.15);background:rgba(255,214,10,.02)}
|
||
.gm-card-top{display:flex;align-items:center;gap:12px;margin-bottom:14px}
|
||
.gm-card-avatar{width:46px;height:46px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:900;font-size:20px;color:#fff;flex-shrink:0}
|
||
.gm-card-info{flex:1;min-width:0}
|
||
.gm-card-name{font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.gm-card-alias{font-size:14px;color:var(--cyan);margin-top:2px}
|
||
.gm-card-badges{display:flex;gap:5px;flex-wrap:wrap;margin-top:2px}
|
||
.gm-card-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:14px}
|
||
.gm-stat{background:var(--bg3);border-radius:8px;padding:8px 10px;text-align:center}
|
||
.gm-stat-val{font-family:'Exo 2',sans-serif;font-weight:700;font-size:16px;color:var(--gold)}
|
||
.gm-stat-lbl{font-size:12px;color:var(--text2);font-weight:600;letter-spacing:.5px;margin-top:1px}
|
||
.gm-card-email{font-size:13px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.gm-card-joined{font-size:12px;color:var(--text2);margin-top:3px}
|
||
.gm-card-hover-cta{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(240,192,64,.12),transparent);padding:8px 16px;font-size:13px;font-weight:700;color:var(--gold);letter-spacing:.5px;opacity:0;transition:opacity .2s;text-align:center}
|
||
.gm-card:hover .gm-card-hover-cta{opacity:1}
|
||
|
||
.gm-back-btn{display:flex;align-items:center;gap:7px;background:none;border:1px solid var(--border);border-radius:8px;color:var(--text2);cursor:pointer;font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;padding:8px 14px;margin-bottom:18px;transition:all .15s}
|
||
.gm-back-btn:hover{color:var(--cyan);border-color:var(--cyan)}
|
||
|
||
.gm-profile-card{background:linear-gradient(135deg,#1a1228,#0d1a2e);border:1px solid rgba(240,192,64,.2);border-radius:var(--r);padding:22px;margin-bottom:18px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||
.gm-profile-avatar{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:900;font-size:30px;color:#fff;flex-shrink:0;border:3px solid var(--gold);box-shadow:0 0 20px rgba(240,192,64,.3)}
|
||
.gm-profile-info{flex:1;min-width:0}
|
||
.gm-profile-name{font-family:'Exo 2',sans-serif;font-weight:900;font-size:22px;color:var(--text)}
|
||
.gm-profile-alias{font-size:15px;color:var(--cyan);margin-top:2px}
|
||
.gm-profile-email{font-size:15px;color:var(--text2);margin-top:4px}
|
||
.gm-profile-right{text-align:right;flex-shrink:0}
|
||
.gm-profile-tokens{font-family:'Exo 2',sans-serif;font-weight:900;font-size:32px;color:var(--gold);line-height:1}
|
||
.gm-profile-tok-lbl{font-size:13px;color:var(--text2);margin-top:2px;letter-spacing:1px;text-transform:uppercase}
|
||
.gm-profile-badges{display:flex;gap:6px;margin-top:8px;justify-content:flex-end}
|
||
|
||
.gm-tabs{display:flex;gap:0;background:var(--bg3);border-radius:var(--r);padding:4px;margin-bottom:16px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||
.gm-tabs::-webkit-scrollbar{display:none}
|
||
.gm-tab{flex:1;padding:9px 12px;border:none;background:none;color:var(--text2);font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;letter-spacing:.3px;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap}
|
||
.gm-tab.active{background:linear-gradient(135deg,rgba(240,192,64,.15),rgba(0,229,255,.08));color:var(--gold)}
|
||
|
||
.gm-tab-panel{display:none}
|
||
.gm-tab-panel.active{display:block;animation:fadeUp .2s ease}
|
||
|
||
.gm-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:16px}
|
||
.gm-info-item{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:14px}
|
||
.gm-info-label{font-size:12px;font-weight:700;color:var(--text2);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:5px}
|
||
.gm-info-val{font-size:15px;font-weight:600;color:var(--text)}
|
||
|
||
.gm-actions-panel{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:16px}
|
||
.gm-actions-title{font-size:13px;font-weight:700;color:var(--text2);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:12px}
|
||
.gm-action-btns{display:flex;gap:8px;flex-wrap:wrap}
|
||
|
||
.gm-token-display{font-family:'Exo 2',sans-serif;font-weight:900;font-size:48px;color:var(--gold);text-align:center;padding:16px 0}
|
||
.gm-edit-label{display:block;font-size:13px;font-weight:700;color:var(--text2);letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}
|
||
.gm-empty{text-align:center;padding:28px;color:var(--text2);font-size:15px}
|
||
|
||
/* ─── PAYMENT LEDGER ─────────────────────────────────── */
|
||
.pm-summary{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}
|
||
.pm-sum-item{background:var(--bg3);border:1px solid var(--border);border-radius:var(--rs);padding:10px;text-align:center}
|
||
.pm-sum-val{font-family:'Exo 2',sans-serif;font-weight:900;font-size:18px;line-height:1}
|
||
.pm-sum-lbl{font-size:12px;color:var(--text2);font-weight:700;letter-spacing:.5px;text-transform:uppercase;margin-top:3px}
|
||
|
||
.pm-card{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:14px;margin-bottom:10px;transition:border-color .15s}
|
||
.pm-card.pm-completed{border-left:3px solid var(--green)}
|
||
.pm-card.pm-pending{border-left:3px solid var(--yellow)}
|
||
.pm-card.pm-failed{border-left:3px solid var(--red);opacity:.85}
|
||
.pm-card-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px}
|
||
.pm-order-id{font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text)}
|
||
.pm-custom-badge{background:rgba(155,93,229,.2);color:#c77dff;font-size:12px;font-weight:700;padding:1px 7px;border-radius:10px;margin-left:6px;letter-spacing:.3px}
|
||
.pm-date{font-size:13px;color:var(--text2);margin-top:2px}
|
||
.pm-card-right{text-align:right}
|
||
.pm-amount{font-family:'Exo 2',sans-serif;font-weight:900;font-size:22px;color:var(--green);line-height:1}
|
||
.pm-tokens{font-size:14px;color:var(--gold);font-weight:700;margin-top:2px}
|
||
.pm-card-mid{}
|
||
.pm-detail-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:14px}
|
||
.pm-lbl{color:var(--text2);font-weight:700}
|
||
.pm-val{color:var(--text)}
|
||
.pm-card-info{font-size:13px;color:var(--text2);background:var(--bg);border-radius:6px;padding:2px 8px}
|
||
.pm-failure{background:rgba(255,68,68,.08);border:1px solid rgba(255,68,68,.2);border-radius:6px;padding:7px 10px;margin-top:8px;font-size:14px;color:var(--red)}
|
||
.pm-admin-note{background:rgba(240,192,64,.05);border:1px solid rgba(240,192,64,.15);border-radius:6px;padding:7px 10px;margin-top:6px;font-size:14px;color:var(--gold2)}
|
||
.pm-receipt-link{font-size:14px;color:var(--cyan);text-decoration:none;font-weight:600}
|
||
.pm-receipt-link:hover{text-decoration:underline}
|
||
.pm-actions{display:flex;gap:8px;align-items:center;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)}
|
||
.btn-sm{padding:6px 12px;font-size:13px}
|
||
|
||
/* ── Responsive Typography ─────────────────────────────── */
|
||
@media (min-width: 768px) {
|
||
body { font-size: 17px; }
|
||
.card { font-size: 16px !important; padding: 20px !important; }
|
||
.page-title { font-size: 26px !important; }
|
||
.nav-item { font-size: 15px !important; }
|
||
}
|
||
@media (min-width: 1200px) {
|
||
body { font-size: 18px; }
|
||
.card { font-size: 17px !important; }
|
||
.page-title { font-size: 28px !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<aside>
|
||
<div class="sidebar-head">
|
||
<div class="sidebar-logo" style="display:flex;align-items:center;gap:8px"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="28" height="28" style="display:inline-block;vertical-align:middle;flex-shrink:0"><defs><linearGradient id="li1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#f0c040"/><stop offset="100%" stop-color="#ff6b35"/></linearGradient><linearGradient id="li2" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00e5ff"/><stop offset="100%" stop-color="#7b2fbe"/></linearGradient></defs><rect x="6" y="16" width="36" height="22" rx="11" fill="url(#li1)"/><rect x="12" y="23" width="8" height="3" rx="1.5" fill="rgba(0,0,0,0.45)"/><rect x="15" y="20" width="3" height="8" rx="1.5" fill="rgba(0,0,0,0.45)"/><circle cx="32" cy="22" r="2.2" fill="#e63946" opacity=".9"/><circle cx="36" cy="25" r="2.2" fill="#2ec4b6" opacity=".9"/><circle cx="32" cy="28" r="2.2" fill="#7b2fbe" opacity=".9"/><circle cx="28" cy="25" r="2.2" fill="#f4a261" opacity=".9"/><rect x="21" y="24" width="6" height="3" rx="1.5" fill="rgba(0,0,0,0.3)"/><rect x="8" y="30" width="8" height="7" rx="4" fill="url(#li2)" opacity=".7"/><rect x="32" y="30" width="8" height="7" rx="4" fill="url(#li2)" opacity=".7"/><rect x="14" y="13" width="8" height="5" rx="2.5" fill="url(#li1)" opacity=".8"/><rect x="26" y="13" width="8" height="5" rx="2.5" fill="url(#li1)" opacity=".8"/></svg><span>TomTomGames</span></div>
|
||
<div class="sidebar-sub" style="-webkit-text-fill-color:var(--text2)">ADMIN PANEL</div>
|
||
</div>
|
||
<div class="nav-section">Main</div>
|
||
<button class="nav-item active" onclick="showSec('dashboard')">📊 Dashboard</button>
|
||
<button class="nav-item" onclick="showSec('users')">🎮 Players</button>
|
||
<button class="nav-item" onclick="showSec('pending')">⏳ Pending Signups <span class="badge-count" id="badge-pending">0</span></button>
|
||
<button class="nav-item" onclick="showSec('purchases')">🧾 Purchases <span class="badge-count" id="badge-purchases">0</span></button>
|
||
<button class="nav-item" onclick="showSec('cashouts')">💸 Cashouts <span class="badge-count" id="badge-cashouts">0</span></button>
|
||
<button class="nav-item" onclick="showSec('chat')">💬 Live Chat <span class="badge-count" id="badge-chat">0</span></button>
|
||
<button class="nav-item" onclick="showSec('broadcasts')">📢 Broadcasts</button>
|
||
<div class="nav-section">Manage</div>
|
||
<button class="nav-item" onclick="showSec('games')">🕹️ Game Management</button>
|
||
<button class="nav-item" onclick="showSec('referrals')">🎁 Referrals</button>
|
||
<button class="nav-item" onclick="showSec('platform-accounts')">🔑 Platform Accounts</button>
|
||
<button class="nav-item" onclick="showSec('payments')">💳 Payment Settings</button>
|
||
<button class="nav-item" onclick="showSec('cashout-methods')">💸 Cashout Methods</button>
|
||
<button class="nav-item" onclick="showSec('payout-settings')">💰 Payout Settings</button>
|
||
<button class="nav-item" onclick="showSec('history')">📋 All History</button>
|
||
<div class="sidebar-footer">
|
||
<button class="logout-btn" onclick="fetch('/api/logout.php').then(()=>location='/admin/login.php')">🚪 LOGOUT</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main>
|
||
|
||
<!-- DASHBOARD -->
|
||
<div class="section active" id="section-dashboard">
|
||
<div class="page-title">📊 Dashboard</div>
|
||
<!-- Unread messages alert -->
|
||
<div id="unread-chat-alert" style="display:none;background:linear-gradient(135deg,rgba(240,192,64,.12),rgba(0,229,255,.06));border:1px solid rgba(240,192,64,.3);border-radius:12px;padding:14px 18px;margin-bottom:16px;display:none;align-items:center;gap:14px;cursor:pointer" onclick="showSec('chat')">
|
||
<div style="font-size:28px">💬</div>
|
||
<div style="flex:1">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;color:var(--gold)">New Messages from Players</div>
|
||
<div id="unread-chat-alert-text" style="font-size:14px;color:var(--text2);margin-top:2px">Click to view and respond</div>
|
||
</div>
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:900;font-size:24px;color:var(--gold)" id="unread-chat-alert-count">0</div>
|
||
</div>
|
||
<div class="stats-grid">
|
||
<div class="stat-card"><div class="stat-label">Total Users</div><div class="stat-value" id="s-users">—</div></div>
|
||
<div class="stat-card"><div class="stat-label">Total Revenue</div><div class="stat-value" id="s-rev" style="color:var(--green)">—</div></div>
|
||
<div class="stat-card"><div class="stat-label">Tokens Sold</div><div class="stat-value" id="s-toks">—</div></div>
|
||
<div class="stat-card"><div class="stat-label">Pending Purchases</div><div class="stat-value" id="s-ppurch" style="color:var(--yellow)">—</div></div>
|
||
<div class="stat-card"><div class="stat-label">Pending Cashouts</div><div class="stat-value" id="s-pcash" style="color:var(--yellow)">—</div></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">⚡ Pending Purchase Approvals</div>
|
||
<div id="dash-purchases"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">⚡ Pending Cashout Requests</div>
|
||
<div id="dash-cashouts"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PURCHASES -->
|
||
<div class="section" id="section-purchases">
|
||
<div class="page-title">🧾 Token Purchases</div>
|
||
<div class="filter-bar">
|
||
<button class="ftab active" onclick="loadPurchases('pending',this)">⏳ Pending Approval</button>
|
||
<button class="ftab" onclick="loadPurchases('completed',this)">✅ Completed</button>
|
||
<button class="ftab" onclick="loadPurchases('failed',this)">❌ Failed</button>
|
||
<button class="ftab" onclick="loadPurchases('all',this)">📋 All</button>
|
||
</div>
|
||
<div id="purchases-list"></div>
|
||
</div>
|
||
|
||
<!-- CASHOUTS -->
|
||
<div class="section" id="section-cashouts">
|
||
<div class="page-title">💸 Cashout Requests</div>
|
||
<div class="filter-bar">
|
||
<button class="ftab active" onclick="loadCashouts('pending',this)">⏳ Pending</button>
|
||
<button class="ftab" onclick="loadCashouts('sent',this)">✅ Sent</button>
|
||
<button class="ftab" onclick="loadCashouts('rejected',this)">❌ Denied</button>
|
||
<button class="ftab" onclick="loadCashouts('deleted',this)">🗑 Deleted</button>
|
||
</div>
|
||
<div id="cashouts-table"></div>
|
||
</div>
|
||
|
||
<!-- USERS -->
|
||
<!-- GAMER MANAGEMENT -->
|
||
<div class="section" id="section-users">
|
||
<!-- LIST VIEW -->
|
||
<div id="gm-list-view">
|
||
<div class="page-title">🎮 Gamer Management</div>
|
||
<div class="gm-toolbar">
|
||
<div class="gm-search-wrap">
|
||
<svg class="gm-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input class="gm-search" id="gm-search" type="text" placeholder="Search username, alias, email..." oninput="filterGamers(this.value)">
|
||
</div>
|
||
<div class="gm-filters">
|
||
<button class="gm-filter active" onclick="setGamerFilter('all',this)">All</button>
|
||
<button class="gm-filter" onclick="setGamerFilter('active',this)">Active</button>
|
||
<button class="gm-filter" onclick="setGamerFilter('suspended',this)">Suspended</button>
|
||
<button class="gm-filter" onclick="setGamerFilter('unverified',this)">Unverified</button>
|
||
</div>
|
||
<div class="gm-stats-mini" id="gm-stats-mini"></div>
|
||
</div>
|
||
<div class="gm-grid" id="gm-grid"></div>
|
||
</div>
|
||
|
||
<!-- PLAYER DETAIL VIEW -->
|
||
<div id="gm-detail-view" style="display:none">
|
||
<button class="gm-back-btn" onclick="showGamerList()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="16" height="16"><polyline points="15 18 9 12 15 6"/></svg>
|
||
Back to Players
|
||
</button>
|
||
<div class="gm-profile-card" id="gm-profile-card"></div>
|
||
<div class="gm-tabs">
|
||
<button class="gm-tab active" onclick="switchGamerTab('overview',this)">Overview</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('aliases',this)">🎮 Aliases</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('payout',this)">💸 Payout</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('platforms',this)">🔑 Accounts</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('tokens',this)">Tokens</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('purchases',this)">Purchases</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('cashouts',this)">Cashouts</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('billing',this)">Billing</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('chat',this)">Chat</button>
|
||
<button class="gm-tab" onclick="switchGamerTab('edit',this)">Edit</button>
|
||
</div>
|
||
<!-- TAB: OVERVIEW -->
|
||
<div class="gm-tab-panel active" id="gtab-overview">
|
||
<div class="gm-info-grid" id="gm-info-grid"></div>
|
||
<div class="gm-actions-panel">
|
||
<div class="gm-actions-title">Quick Actions</div>
|
||
<div class="gm-action-btns" id="gm-action-btns"></div>
|
||
</div>
|
||
</div>
|
||
<!-- TAB: PLATFORM ACCOUNTS -->
|
||
<div class="gm-tab-panel" id="gtab-platforms">
|
||
<div class="card" style="margin-bottom:12px">
|
||
<div class="card-title">🔑 Platform Accounts</div>
|
||
<div style="font-size:14px;color:var(--text2);margin-bottom:12px">Manage game platform logins for this player. When you approve a request and set a username/password, it will automatically update their alias.</div>
|
||
<div id="gm-platform-accounts-list"><div style="color:var(--text2);text-align:center;padding:16px">Loading...</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB: PAYOUT METHODS -->
|
||
<div class="gm-tab-panel" id="gtab-payout">
|
||
<div class="card">
|
||
<div class="card-title">💸 Payout Methods</div>
|
||
<div style="font-size:14px;color:var(--text2);margin-bottom:12px">Where this player receives their cashout payments.</div>
|
||
<div id="gm-payout-list"><div style="color:var(--text2);text-align:center;padding:16px">Loading...</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB: PLATFORM ACCOUNTS -->
|
||
<div class="gm-tab-panel" id="gtab-platforms">
|
||
<div class="card">
|
||
<div class="card-title">🔑 Platform Accounts</div>
|
||
<div style="font-size:14px;color:var(--text2);margin-bottom:12px">Game platform logins created for this player.</div>
|
||
<div id="gm-platforms-list"><div style="color:var(--text2);text-align:center;padding:16px">Loading...</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB: ALIASES -->
|
||
<div class="gm-tab-panel" id="gtab-aliases">
|
||
<div class="card" style="margin-bottom:12px">
|
||
<div class="card-title">🎮 Game Aliases</div>
|
||
<div style="font-size:14px;color:var(--text2);margin-bottom:14px">Player's in-game usernames per platform. Edit and save below.</div>
|
||
<div id="gm-aliases-list"><div style="color:var(--text2);text-align:center;padding:16px;font-size:15px">Loading...</div></div>
|
||
<div id="gm-aliases-alert" class="alert" style="margin-top:10px"></div>
|
||
<button class="btn btn-gold" onclick="gmSaveAllAliases()" style="margin-top:12px">💾 SAVE ALL ALIASES</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB: TOKENS -->
|
||
<div class="gm-tab-panel" id="gtab-tokens">
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div class="card-title">💰 Token Balance</div>
|
||
<div class="gm-token-display" id="gm-token-display">—</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div class="card-title">Adjust Tokens</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
|
||
<div><label class="gm-edit-label">Amount</label><input class="fi-sm" type="number" id="gm-tok-amount" placeholder="e.g. 50" style="width:100%;padding:10px 12px;font-size:15px"></div>
|
||
<div><label class="gm-edit-label">Reason</label><input class="fi-sm" type="text" id="gm-tok-reason" placeholder="e.g. Bonus" style="width:100%;padding:10px 12px;font-size:15px"></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-green" style="flex:1" onclick="gmAdjustTokens(1)">➕ ADD</button>
|
||
<button class="btn btn-red" style="flex:1" onclick="gmAdjustTokens(-1)">➖ REMOVE</button>
|
||
</div>
|
||
<div id="gm-tok-msg" class="alert" style="margin-top:12px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">Set Exact Balance</div>
|
||
<div style="display:flex;gap:8px">
|
||
<input class="fi-sm" type="number" id="gm-tok-set" placeholder="New balance" style="flex:1;padding:10px 12px;font-size:15px">
|
||
<button class="btn btn-gold" style="width:100px" onclick="gmSetTokens()">SET</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- TAB: PURCHASES -->
|
||
<div class="gm-tab-panel" id="gtab-purchases">
|
||
<div id="gm-purchases-table"></div>
|
||
</div>
|
||
<!-- TAB: CASHOUTS -->
|
||
<div class="gm-tab-panel" id="gtab-cashouts">
|
||
<div class="card"><div class="tbl-wrap"><div id="gm-cashouts-table"></div></div></div>
|
||
</div>
|
||
<!-- TAB: BILLING -->
|
||
<div class="gm-tab-panel" id="gtab-billing">
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div class="card-title">💳 Saved Payment Info</div>
|
||
<div id="gm-billing-card-display" style="display:none;margin-bottom:16px">
|
||
<div class="admin-saved-card" id="gm-billing-card"></div>
|
||
<div style="display:flex;gap:8px;margin-top:10px">
|
||
<button class="btn btn-red" style="flex:1" onclick="gmClearCard()">🗑 Remove Card</button>
|
||
</div>
|
||
</div>
|
||
<div id="gm-billing-no-card" style="display:none;padding:12px;background:var(--bg3);border-radius:var(--rs);font-size:15px;color:var(--text2);text-align:center;margin-bottom:12px">No card on file</div>
|
||
<div id="gm-billing-alert" class="alert" style="margin-bottom:12px"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">
|
||
<div><label class="gm-edit-label">First Name</label><input class="fi-sm" id="gm-b-first" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">Last Name</label><input class="fi-sm" id="gm-b-last" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">Email</label><input class="fi-sm" id="gm-b-email" type="email" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">ZIP Code</label><input class="fi-sm" id="gm-b-zip" type="text" style="width:100%;padding:10px 12px"></div>
|
||
</div>
|
||
<div style="margin-bottom:10px"><label class="gm-edit-label">Street Address</label><input class="fi-sm" id="gm-b-address" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 70px;gap:8px;margin-bottom:14px">
|
||
<div><label class="gm-edit-label">City</label><input class="fi-sm" id="gm-b-city" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">State</label><input class="fi-sm" id="gm-b-state" type="text" maxlength="2" style="width:100%;padding:10px 12px;text-transform:uppercase"></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-gold" style="flex:1" onclick="gmSaveBilling()">💾 SAVE BILLING INFO</button>
|
||
<button class="btn btn-red" style="width:120px" onclick="gmClearAllBilling()">🗑 Clear All</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB: CHAT -->
|
||
<div class="gm-tab-panel" id="gtab-chat">
|
||
<div class="admin-chat-wrap" style="height:400px">
|
||
<div class="admin-chat-messages" id="gm-chat-messages"></div>
|
||
<div class="admin-chat-input-bar">
|
||
<input class="admin-chat-input" id="gm-chat-input" type="text" placeholder="Message this player..." onkeydown="if(event.key==='Enter')gmSendMsg()">
|
||
<button class="admin-send-btn" onclick="gmSendMsg()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button onclick="gmClearThread()" style="margin-top:10px;width:100%;background:rgba(255,68,68,.08);border:1px solid rgba(255,68,68,.2);color:var(--red);border-radius:8px;padding:10px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;cursor:pointer">
|
||
🗑 Clear This Conversation
|
||
</button>
|
||
</div>
|
||
<!-- TAB: EDIT -->
|
||
<div class="gm-tab-panel" id="gtab-edit">
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div class="card-title">✏️ Edit Player Info</div>
|
||
<div id="gm-edit-alert" class="alert" style="margin-bottom:12px"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
||
<div><label class="gm-edit-label">Username</label><input class="fi-sm" id="gm-edit-username" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">Alias / Nickname</label><input class="fi-sm" id="gm-edit-alias" type="text" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">Email Address</label><input class="fi-sm" id="gm-edit-email" type="email" style="width:100%;padding:10px 12px"></div>
|
||
<div><label class="gm-edit-label">New Password <span style="color:var(--text2);font-weight:400">(blank = keep)</span></label><input class="fi-sm" id="gm-edit-password" type="password" placeholder="Leave blank to keep" style="width:100%;padding:10px 12px"></div>
|
||
</div>
|
||
<button class="btn btn-gold" onclick="gmSaveEdit()">💾 SAVE CHANGES</button>
|
||
</div>
|
||
<div class="card" style="border-color:rgba(255,68,68,.25)">
|
||
<div class="card-title" style="color:var(--red)">⚠️ Danger Zone</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<button class="btn btn-red" id="gm-suspend-btn" onclick="gmToggleSuspend()">Loading...</button>
|
||
<button class="btn" onclick="gmSendPasswordReset()" style="background:rgba(155,93,229,.12);color:#c77dff;border:1px solid rgba(155,93,229,.3)">🔑 Send Password Reset Email</button>
|
||
<button class="btn" id="gm-admin-toggle-btn" onclick="gmToggleAdmin()">Admin Toggle</button>
|
||
<button class="btn" onclick="gmDeleteAccount()" style="background:rgba(255,30,30,.18);color:#ff6666;border:1px solid rgba(255,30,30,.35)">🗑️ DELETE ACCOUNT PERMANENTLY</button>
|
||
</div>
|
||
<p style="font-size:15px;color:var(--text2);margin-top:12px;line-height:1.6">Deleting is permanent and removes all purchases, cashouts, and chat history.</p>
|
||
</div>
|
||
</div>
|
||
</div><!-- /gm-detail-view -->
|
||
</div><!-- /section-users -->
|
||
|
||
<!-- ── PAYMENT SETTINGS ─────────────────────────────────── -->
|
||
<div class="section" id="section-payments">
|
||
<div class="page-title">💳 Payment Settings</div>
|
||
<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:10px;padding:12px 16px;margin-bottom:16px;font-size:15px;color:var(--text2);line-height:1.6">
|
||
Enable or disable each payment method. Disabled methods are hidden from players instantly. Card payments run through Square — your Square account must be active for card to work.
|
||
</div>
|
||
<div id="payment-methods-list"></div>
|
||
</div>
|
||
|
||
<!-- ── PLATFORM ACCOUNTS ─────────────────────────────── -->
|
||
<!-- ── REFERRALS ──────────────────────────────────────── -->
|
||
<div class="section" id="section-referrals">
|
||
<div class="page-title">🎁 Referral Management</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||
<button class="ftab active" onclick="loadAdminReferrals('pending',this)">⏳ Pending</button>
|
||
<button class="ftab" onclick="loadAdminReferrals('verified',this)">✅ Verified</button>
|
||
<button class="ftab" onclick="loadAdminReferrals('denied',this)">❌ Denied</button>
|
||
<button class="ftab" onclick="showRefSection('tiers')">⚙️ Manage Tiers</button>
|
||
<button class="ftab" onclick="showRefSection('shares')">📱 Social Shares</button>
|
||
</div>
|
||
<div id="ref-admin-list"></div>
|
||
<div id="ref-tiers-section" style="display:none">
|
||
<div class="card" style="margin-bottom:14px;border-color:rgba(240,192,64,.2)">
|
||
<div class="card-title" id="ref-tier-form-title">Add Referral Tier</div>
|
||
<div id="ref-tier-alert" class="alert" style="margin-bottom:8px"></div>
|
||
<input type="hidden" id="rt-id">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<div><label class="gm-edit-label">Tier Name</label><input class="fi-sm" id="rt-name" type="text" placeholder="e.g. Gold Referrer" style="width:100%;padding:8px 10px"></div>
|
||
<div><label class="gm-edit-label">Min Referrals</label><input class="fi-sm" id="rt-min" type="number" min="1" value="1" style="width:100%;padding:8px 10px"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<div><label class="gm-edit-label">Tokens per Referral</label><input class="fi-sm" id="rt-per" type="number" min="0" step="0.5" value="5" style="width:100%;padding:8px 10px"></div>
|
||
<div><label class="gm-edit-label">Milestone Bonus</label><input class="fi-sm" id="rt-bonus" type="number" min="0" value="0" style="width:100%;padding:8px 10px"></div>
|
||
<div><label class="gm-edit-label">Sort Order</label><input class="fi-sm" id="rt-sort" type="number" value="0" style="width:100%;padding:8px 10px"></div>
|
||
</div>
|
||
<div style="margin-bottom:10px"><label class="gm-edit-label">Description</label><input class="fi-sm" id="rt-desc" type="text" placeholder="Shown to players" style="width:100%;padding:8px 10px"></div>
|
||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||
<div style="flex:1"><label class="gm-edit-label">Status</label>
|
||
<select class="fi-sm" id="rt-active" style="width:100%;padding:8px 10px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:var(--rs)">
|
||
<option value="1">Active</option><option value="0">Inactive</option>
|
||
</select></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-gold" onclick="saveRefTier()" style="flex:1">Save Tier</button>
|
||
<button class="btn btn-outline" onclick="resetRefTierForm()" style="width:100px">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px">All Tiers</div>
|
||
<div id="ref-tiers-admin-list"></div>
|
||
</div>
|
||
</div>
|
||
<div id="ref-shares-section" style="display:none">
|
||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||
<button class="ftab active" onclick="loadAdminShares('pending',this)">Pending</button>
|
||
<button class="ftab" onclick="loadAdminShares('approved',this)">Approved</button>
|
||
<button class="ftab" onclick="loadAdminShares('denied',this)">Denied</button>
|
||
</div>
|
||
<div id="ref-shares-admin-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="section-platform-accounts">
|
||
<div class="page-title">🔑 Platform Account Requests</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||
<button class="ftab active" onclick="loadPlatformAccountRequests('pending',this)">⏳ Pending</button>
|
||
<button class="ftab" onclick="loadPlatformAccountRequests('approved',this)">✅ Approved</button>
|
||
<button class="ftab" onclick="loadPlatformAccountRequests('denied',this)">❌ Denied</button>
|
||
</div>
|
||
<div id="pa-requests-list"></div>
|
||
</div>
|
||
|
||
<!-- ── BROADCASTS ──────────────────────────────────────── -->
|
||
<div class="section" id="section-broadcasts">
|
||
<div class="page-title">📢 Broadcast Messages</div>
|
||
|
||
<!-- Compose panel -->
|
||
<div class="card" style="margin-bottom:16px;border-color:rgba(240,192,64,.2)">
|
||
<div class="card-title" style="color:var(--gold)">✉️ Send Broadcast</div>
|
||
<div id="bc-alert" class="alert" style="margin-bottom:10px"></div>
|
||
<div class="fg" style="margin-bottom:10px">
|
||
<label class="gm-edit-label">Subject *</label>
|
||
<input class="fi-sm" id="bc-subject" type="text" placeholder="e.g. Important Update" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div class="fg" style="margin-bottom:10px">
|
||
<label class="gm-edit-label">Message *</label>
|
||
<textarea id="bc-message" rows="4" placeholder="Write your message..." style="width:100%;box-sizing:border-box;background:var(--bg3);border:1px solid var(--border);border-radius:var(--rs);padding:10px 12px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:14px;resize:vertical;outline:none"></textarea>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end">
|
||
<div>
|
||
<label class="gm-edit-label">Send To</label>
|
||
<select class="fi-sm" id="bc-target" style="width:100%;padding:10px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:var(--rs)">
|
||
<option value="all">👥 All Active Players</option>
|
||
<option value="verified">✅ Verified Players Only</option>
|
||
<option value="unverified">⏳ Unverified Players Only</option>
|
||
<option value="admins">🔑 Admins Only</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-gold" style="padding:11px 20px;white-space:nowrap" onclick="sendBroadcast()">📢 SEND BROADCAST</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sent broadcasts list -->
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px">Sent Broadcasts</div>
|
||
<button onclick="loadBroadcasts()" style="background:none;border:none;color:var(--cyan);cursor:pointer;font-size:14px;font-weight:700">↻ Refresh</button>
|
||
</div>
|
||
<div id="bc-list"></div>
|
||
</div>
|
||
|
||
<!-- Replies / Reads drawer -->
|
||
<div id="bc-drawer" style="display:none;margin-top:12px">
|
||
<div class="card" style="border-color:rgba(0,229,255,.2)">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||
<div class="card-title" id="bc-drawer-title" style="margin-bottom:0">Details</div>
|
||
<button onclick="closeBcDrawer()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px">✕</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||
<button class="ftab active" onclick="switchBcTab('replies',this)" id="bc-tab-replies">💬 Replies</button>
|
||
<button class="ftab" onclick="switchBcTab('reads',this)" id="bc-tab-reads">👁 Read By</button>
|
||
</div>
|
||
<div id="bc-drawer-content"></div>
|
||
<!-- Admin reply -->
|
||
<div style="margin-top:14px;display:flex;gap:8px">
|
||
<input class="fi-sm" id="bc-reply-input" type="text" placeholder="Reply to this broadcast..." style="flex:1;padding:10px 12px" onkeydown="if(event.key==='Enter')adminBroadcastReply()">
|
||
<button class="btn btn-gold" onclick="adminBroadcastReply()" style="padding:10px 16px">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── PAYOUT SETTINGS ─────────────────────────────── -->
|
||
<div class="section" id="section-payout-settings">
|
||
<div class="page-title">💰 Payout Settings</div>
|
||
<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:10px;padding:12px 16px;margin-bottom:16px;font-size:15px;color:var(--text2);line-height:1.7">
|
||
Configure how you send cashout payments to players. <strong style="color:var(--cyan)">Square Gift Card</strong> sends instantly. <strong style="color:var(--gold)">Manual methods</strong> show the player handle so you send from the app and mark done.
|
||
</div>
|
||
<div id="payout-settings-list"></div>
|
||
</div>
|
||
|
||
<!-- ── CASHOUT METHOD TYPES ──────────────────────────────── -->
|
||
<div class="section" id="section-cashout-methods">
|
||
<div class="page-title">💸 Cashout Methods</div>
|
||
<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:10px;padding:12px 16px;margin-bottom:16px;font-size:15px;color:var(--text2);line-height:1.6">
|
||
💸 Manage the payout method types available to players when they cash out. Active methods appear in the player's payout method dropdown.
|
||
</div>
|
||
|
||
<!-- Add / Edit form -->
|
||
<div class="card" id="cmt-form-card" style="margin-bottom:16px">
|
||
<div class="card-title" id="cmt-form-title">➕ Add Cashout Method</div>
|
||
<div id="cmt-form-alert" class="alert" style="margin-bottom:10px"></div>
|
||
<input type="hidden" id="cmt-id" value="">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">
|
||
<div>
|
||
<label class="gm-edit-label">Label *</label>
|
||
<input class="fi-sm" id="cmt-label" type="text" placeholder="e.g. Venmo" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Slug * <span style="font-weight:400;color:var(--text2)">lowercase, no spaces</span></label>
|
||
<input class="fi-sm" id="cmt-slug" type="text" placeholder="e.g. venmo" style="width:100%;padding:10px 12px" oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_]/g,'')">
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:80px 1fr;gap:10px;margin-bottom:10px">
|
||
<div>
|
||
<label class="gm-edit-label">Icon</label>
|
||
<input class="fi-sm" id="cmt-icon" type="text" value="💰" maxlength="4" style="width:100%;padding:10px 12px;font-size:20px;text-align:center">
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Description <span style="font-weight:400;color:var(--text2)">shown to players</span></label>
|
||
<input class="fi-sm" id="cmt-desc" type="text" placeholder="e.g. Send via Venmo username" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
|
||
<div>
|
||
<label class="gm-edit-label">Sort Order</label>
|
||
<input class="fi-sm" id="cmt-sort" type="number" value="99" min="0" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Status</label>
|
||
<select class="fi-sm" id="cmt-active" style="width:100%;padding:10px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:var(--rs)">
|
||
<option value="1">✅ Active</option>
|
||
<option value="0">⏸ Inactive</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-gold" style="flex:1" onclick="saveCashoutMethod()">💾 SAVE</button>
|
||
<button class="btn btn-outline" onclick="resetCmtForm()" style="width:100px">CLEAR</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Methods list -->
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px">Active & Inactive Methods</div>
|
||
<div style="font-size:14px;color:var(--text2)" id="cmt-count">Loading...</div>
|
||
</div>
|
||
<div id="cmt-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── GAME MANAGEMENT ─────────────────────────────────── -->
|
||
<div class="section" id="section-games">
|
||
<div class="page-title">🕹️ Game Management</div>
|
||
|
||
<!-- Add / Edit form -->
|
||
<div class="card" id="game-form-card" style="margin-bottom:16px">
|
||
<div class="card-title" id="game-form-title">➕ Add New Game</div>
|
||
<div id="game-form-alert" class="alert" style="margin-bottom:10px"></div>
|
||
<input type="hidden" id="gf-id" value="">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">
|
||
<div>
|
||
<label class="gm-edit-label">Game Name *</label>
|
||
<input class="fi-sm" id="gf-name" type="text" placeholder="e.g. VBlink 777" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Slug (ID) * <span style="font-weight:400;color:var(--text2)">lowercase, no spaces</span></label>
|
||
<input class="fi-sm" id="gf-slug" type="text" placeholder="e.g. vblink777" style="width:100%;padding:10px 12px" oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_]/g,'')">
|
||
</div>
|
||
</div>
|
||
<div style="margin-bottom:10px">
|
||
<label class="gm-edit-label">Player URL * <span style="font-weight:400;color:var(--text2)">shown to players — opens the game</span></label>
|
||
<input class="fi-sm" id="gf-player-url" type="url" placeholder="https://game.example.com/play" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div style="margin-bottom:10px">
|
||
<label class="gm-edit-label">Console / Admin URL <span style="font-weight:400;color:var(--text2)">only visible to admins</span></label>
|
||
<input class="fi-sm" id="gf-console-url" type="url" placeholder="https://admin.game.example.com" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:14px">
|
||
<div>
|
||
<label class="gm-edit-label">Brand Color</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<input type="color" id="gf-color" value="#f0c040" style="width:44px;height:38px;padding:2px;border:1px solid var(--border);border-radius:6px;background:var(--bg3);cursor:pointer">
|
||
<input class="fi-sm" id="gf-color-hex" type="text" value="#f0c040" maxlength="7" style="flex:1;padding:10px 10px" oninput="syncColorPicker(this.value)">
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Sort Order</label>
|
||
<input class="fi-sm" id="gf-sort" type="number" value="99" min="0" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div>
|
||
<label class="gm-edit-label">Status</label>
|
||
<select class="fi-sm" id="gf-active" style="width:100%;padding:10px 12px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:var(--rs)">
|
||
<option value="1">✅ Active</option>
|
||
<option value="0">⏸ Inactive</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-gold" style="flex:1" onclick="saveGame()">💾 SAVE GAME</button>
|
||
<button class="btn btn-outline" onclick="resetGameForm()" style="width:100px">CLEAR</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Games list -->
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px">Active & Inactive Games</div>
|
||
<div style="font-size:14px;color:var(--text2)" id="games-count">Loading...</div>
|
||
</div>
|
||
<div id="games-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── HISTORY ─────────────────────────────────── -->
|
||
<div class="section" id="section-history">
|
||
<div class="page-title">📋 Full Audit Log <span style="font-size:15px;font-weight:400;color:var(--text2);margin-left:8px">90-day rolling · 20 per page</span></div>
|
||
|
||
<!-- Filters row -->
|
||
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center">
|
||
<select id="hist-category" onchange="loadHistory(1)" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;cursor:pointer">
|
||
<option value="">📂 All Categories</option>
|
||
<option value="auth">🔐 Auth (Login/Register)</option>
|
||
<option value="player">👤 Player Actions</option>
|
||
<option value="admin">🛡️ Admin Actions</option>
|
||
<option value="security">⚠️ Security Events</option>
|
||
<option value="general">📋 General</option>
|
||
</select>
|
||
<select id="hist-severity" onchange="loadHistory(1)" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;cursor:pointer">
|
||
<option value="">🔍 All Severity</option>
|
||
<option value="critical">🔴 Critical</option>
|
||
<option value="warning">🟡 Warning</option>
|
||
<option value="info">🟢 Info</option>
|
||
</select>
|
||
<input type="text" id="hist-search" placeholder="Search user, action, detail..." onkeydown="if(event.key==='Enter')loadHistory(1)"
|
||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:14px;width:200px">
|
||
<input type="date" id="hist-date" onchange="loadHistory(1)"
|
||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:14px">
|
||
<button onclick="loadHistory(1)" style="background:rgba(0,229,255,.08);border:1px solid rgba(0,229,255,.2);color:var(--cyan);border-radius:8px;padding:8px 14px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;cursor:pointer">↻ Refresh</button>
|
||
<button onclick="exportHistory()" style="background:rgba(240,192,64,.08);border:1px solid rgba(240,192,64,.2);color:var(--gold);border-radius:8px;padding:8px 14px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;cursor:pointer">⬇ Export CSV</button>
|
||
<span id="hist-count" style="font-size:14px;color:var(--text2);margin-left:4px"></span>
|
||
</div>
|
||
|
||
<!-- Summary stats bar -->
|
||
<div id="hist-stats" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px"></div>
|
||
|
||
<!-- Log table -->
|
||
<div id="history-table"></div>
|
||
|
||
<!-- Pagination -->
|
||
<div id="hist-pagination" style="display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap"></div>
|
||
</div>
|
||
|
||
<!-- PENDING SIGNUPS -->
|
||
<div class="section" id="section-pending">
|
||
<div class="page-title">⏳ Pending Signups</div>
|
||
<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:10px;padding:12px 16px;margin-bottom:14px;font-size:15px;color:var(--text2);line-height:1.6">
|
||
⏳ Players who registered but haven't verified their email yet. <strong style="color:var(--gold)">Approve</strong> to create their account immediately, or <strong style="color:var(--red)">Delete</strong> to remove the request.
|
||
</div>
|
||
<div id="pending-list"></div>
|
||
</div>
|
||
|
||
<!-- CHAT INBOX -->
|
||
<div class="section" id="section-chat">
|
||
<div class="page-title">Live Chat
|
||
<span style="font-size:15px;font-weight:400;color:var(--green);margin-left:10px;letter-spacing:.5px">
|
||
● Auto-refreshing every 5s
|
||
</span>
|
||
</div>
|
||
|
||
<!-- INBOX VIEW -->
|
||
<div id="chat-inbox-view">
|
||
|
||
<!-- NEW MESSAGE COMPOSE PANEL -->
|
||
<div class="card" style="margin-bottom:14px;border-color:rgba(240,192,64,.2);background:linear-gradient(135deg,#1a1228,#0d1820)">
|
||
<div class="card-title" style="color:var(--gold);margin-bottom:12px">✉️ Send Message to Player</div>
|
||
<!-- Player search -->
|
||
<div style="position:relative;margin-bottom:10px">
|
||
<input class="fi-sm" id="compose-search" type="text" placeholder="Search by name, alias or email..."
|
||
style="width:100%;padding:10px 12px;font-size:14px"
|
||
oninput="searchComposePlayers(this.value)" autocomplete="off">
|
||
<div id="compose-dropdown" style="display:none;position:absolute;top:100%;left:0;right:0;background:var(--bg3);border:1px solid var(--border);border-radius:8px;z-index:50;max-height:200px;overflow-y:auto;margin-top:4px;box-shadow:0 8px 24px rgba(0,0,0,.4)"></div>
|
||
</div>
|
||
<!-- Selected player chip -->
|
||
<div id="compose-selected" style="display:none;background:rgba(0,229,255,.08);border:1px solid rgba(0,229,255,.2);border-radius:8px;padding:8px 12px;margin-bottom:10px;display:none;align-items:center;gap:10px">
|
||
<div id="compose-player-avatar" style="width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;color:#fff;flex-shrink:0;background:#7b2fbe">?</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div id="compose-player-name" style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text)">—</div>
|
||
<div id="compose-player-info" style="font-size:15px;color:var(--text2)">—</div>
|
||
</div>
|
||
<button onclick="clearComposePicker()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px;line-height:1;padding:0 4px">×</button>
|
||
</div>
|
||
<!-- Message box -->
|
||
<textarea id="compose-msg" rows="3" placeholder="Type your message..."
|
||
style="width:100%;box-sizing:border-box;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text);font-family:'Rajdhani',sans-serif;font-size:14px;resize:vertical;outline:none;margin-bottom:10px"
|
||
onkeydown="if(event.key==='Enter'&&event.ctrlKey)adminComposeSend()"></textarea>
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<div style="font-size:15px;color:var(--text2)">Ctrl+Enter to send · Player will see it in their Support chat</div>
|
||
<button class="btn btn-gold" style="width:120px" onclick="adminComposeSend()">
|
||
📨 SEND
|
||
</button>
|
||
</div>
|
||
<div id="compose-alert" class="alert" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
<!-- Inbox list -->
|
||
<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:10px;padding:12px 16px;margin-bottom:14px;font-size:15px;color:var(--text2);line-height:1.6;display:flex;align-items:center;gap:10px">
|
||
<span style="flex:1">📨 Messages from players appear below. Click any conversation to open it and reply.</span>
|
||
<button onclick="adminClearAllChats()" style="background:rgba(255,68,68,.1);border:1px solid rgba(255,68,68,.2);color:var(--red);border-radius:8px;padding:7px 12px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;cursor:pointer;white-space:nowrap;flex-shrink:0">
|
||
🗑 Clear All
|
||
</button>
|
||
</div>
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div id="chat-inbox-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- THREAD VIEW -->
|
||
<div id="chat-thread-view" style="display:none">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
||
<button onclick="showChatInbox()" style="background:none;border:none;color:var(--cyan);cursor:pointer;font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;display:flex;align-items:center;gap:6px;padding:0;flex:1">
|
||
← Back to Inbox
|
||
</button>
|
||
<button onclick="adminClearThread()" style="background:rgba(255,68,68,.1);border:1px solid rgba(255,68,68,.2);color:var(--red);border-radius:8px;padding:6px 12px;font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;cursor:pointer">
|
||
🗑 Clear Thread
|
||
</button>
|
||
</div>
|
||
<div class="admin-chat-wrap">
|
||
<div class="admin-chat-header" id="admin-chat-header">
|
||
<div class="admin-chat-avatar" id="admin-chat-avatar">?</div>
|
||
<div>
|
||
<div class="admin-chat-name" id="admin-chat-name">—</div>
|
||
<div class="admin-chat-meta" id="admin-chat-meta">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="admin-chat-messages" id="admin-chat-messages"></div>
|
||
<div class="admin-chat-input-bar">
|
||
<input class="admin-chat-input" id="admin-chat-input" type="text" placeholder="Reply to player..."
|
||
onkeydown="if(event.key==='Enter')adminSendMsg()">
|
||
<button class="admin-send-btn" onclick="adminSendMsg()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PROCESS PAYOUT MODAL -->
|
||
<div id="process-payout-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:900;align-items:center;justify-content:center;padding:20px">
|
||
<div style="background:var(--bg3);border:1px solid rgba(240,192,64,.3);border-radius:16px;padding:28px 24px;max-width:480px;width:100%;max-height:90vh;overflow-y:auto">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:900;font-size:20px;color:var(--gold)">Process Payout</div>
|
||
<button onclick="closePayoutModal()" style="background:none;border:none;color:var(--text2);font-size:20px;cursor:pointer">X</button>
|
||
</div>
|
||
<div id="ppm-player-info" style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:16px"></div>
|
||
<div id="ppm-saved-method" style="margin-bottom:16px"></div>
|
||
<div class="fg" style="margin-bottom:12px">
|
||
<label style="font-size:15px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:1px;display:block;margin-bottom:6px">Send Payment Via</label>
|
||
<select id="ppm-method-select" class="fi fi-select" style="width:100%" onchange="onPayoutMethodChange()">
|
||
<option value="">-- Select payout method --</option>
|
||
</select>
|
||
</div>
|
||
<div id="ppm-method-detail" style="margin-bottom:12px"></div>
|
||
<div class="fg" style="margin-bottom:16px">
|
||
<label style="font-size:15px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:1px;display:block;margin-bottom:6px">Note (optional)</label>
|
||
<input class="fi-sm" type="text" id="ppm-note" placeholder="e.g. Sent via Venmo 3pm" style="width:100%;padding:10px 12px">
|
||
</div>
|
||
<div id="ppm-alert" class="alert" style="margin-bottom:10px"></div>
|
||
<button id="ppm-submit-btn" class="btn btn-gold" onclick="submitPayout()" style="width:100%;font-size:15px;padding:14px">Process Payout</button>
|
||
<div style="font-size:15px;color:var(--text2);text-align:center;margin-top:10px">This marks the cashout as Sent</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
const PLATS = <?= PLATFORMS ?>; // fallback static list
|
||
let _dynamicPlats = null;
|
||
const pname = id => {
|
||
const src = _dynamicPlats || PLATS;
|
||
const p = src.find(p=>(p.id||p.slug)===id);
|
||
return p ? p.name : (id || '—');
|
||
};
|
||
// Load dynamic platforms list from DB
|
||
fetch('/api/platforms.php?action=admin_list').then(r=>r.json()).then(d=>{
|
||
if (d.success) _dynamicPlats = d.platforms.map(p=>({id:p.slug,name:p.name}));
|
||
}).catch(()=>{});
|
||
const CURRENT_ADMIN_ID = <?= (int)$_SESSION['user_id'] ?>;
|
||
const MASTER_ADMIN_ID = <?= MASTER_ADMIN_ID ?>;
|
||
const IS_MASTER_ADMIN = CURRENT_ADMIN_ID === MASTER_ADMIN_ID;
|
||
const methodLabel = m => ({ card:'💳 Card', venmo:'💙 Venmo', chime:'🟢 Chime', cashapp:'💚 Cash App', zelle:'💜 Zelle' }[m] || m);
|
||
const methodClass = m => ({ card:'b-card', venmo:'b-venmo', chime:'b-chime', cashapp:'b-cashapp', zelle:'b-zelle' }[m] || '');
|
||
let allUsers = [];
|
||
|
||
async function apiFetch(action, method='GET', body=null) {
|
||
const opts={method,headers:{'Content-Type':'application/json'}};
|
||
if(body)opts.body=JSON.stringify(body);
|
||
const r = await fetch(`/api/admin.php?action=${action}`,opts);
|
||
return r.json();
|
||
}
|
||
|
||
// ─── INIT ──────────────────────────────────────────────────
|
||
loadStats();
|
||
loadPurchases('pending');
|
||
loadCashouts('pending');
|
||
loadUsers();
|
||
|
||
async function loadStats() {
|
||
const d = await apiFetch('stats');
|
||
if (!d.success) return;
|
||
const s = d.stats;
|
||
document.getElementById('s-users').textContent = s.total_users;
|
||
document.getElementById('s-rev').textContent = '$'+parseFloat(s.total_revenue).toFixed(2);
|
||
document.getElementById('s-toks').textContent = s.total_tokens_sold;
|
||
document.getElementById('s-ppurch').textContent= s.pending_purchases;
|
||
document.getElementById('s-pcash').textContent = s.pending_cashouts;
|
||
document.getElementById('badge-purchases').textContent = s.pending_purchases;
|
||
document.getElementById('badge-cashouts').textContent = s.pending_cashouts;
|
||
const ps = s.pending_signups || 0;
|
||
document.getElementById('badge-pending').textContent = ps;
|
||
document.getElementById('badge-pending').style.display = ps > 0 ? 'inline-block' : 'none';
|
||
// Dashboard quick lists
|
||
loadDashPurchases();
|
||
loadDashCashouts();
|
||
loadPendingSignups();
|
||
}
|
||
|
||
// ─── SECTION NAV ───────────────────────────────────────────
|
||
|
||
// ─── PURCHASE CARD RENDERER ──────────────────────────────
|
||
function purchaseCard(p, showActions=true) {
|
||
const isPending = p.status === 'pending';
|
||
const isManual = p.payment_method !== 'card';
|
||
const amt = (p.amount_cents/100).toFixed(2);
|
||
const date = (p.created_at||'').substring(0,16);
|
||
const statusColors = { pending:'var(--gold)', completed:'var(--green)', failed:'var(--red)' };
|
||
const statusLabels = { pending:'⏳ Pending Approval', completed:'✅ Completed', failed:'❌ Failed' };
|
||
const statusColor = statusColors[p.status] || 'var(--text2)';
|
||
const statusLabel = statusLabels[p.status] || p.status;
|
||
const mIcons = { card:'💳', venmo:'💙', cashapp:'💚', zelle:'💜', chime:'🟢', manual:'💵' };
|
||
const mLabels = { card:'Square Card', venmo:'Venmo', cashapp:'Cash App', zelle:'Zelle', chime:'Chime', manual:'Manual' };
|
||
const mIcon = mIcons[p.payment_method] || '💰';
|
||
const mLabel = mLabels[p.payment_method] || p.payment_method;
|
||
const platformRow = p.platform_id
|
||
? '<div style="font-size:14px;color:var(--text2);margin-top:4px">Platform: <strong style="color:var(--cyan)">'+pname(p.platform_id)+'</strong> · Alias: <strong style="color:var(--gold2)">'+escHtmlA(p.game_alias||'—')+'</strong></div>'
|
||
: '';
|
||
const actions = showActions && isPending
|
||
? '<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;min-width:160px">'+
|
||
'<input class="fi-sm" type="text" id="pnote-'+p.id+'" placeholder="Note to player (optional)" style="width:100%;font-size:14px;padding:8px 10px">'+
|
||
'<button class="btn btn-green" data-pid="'+p.id+'" onclick="resolvePurchase(this.dataset.pid,"completed")" style="width:100%;font-size:15px;padding:10px;font-weight:700">✓ Approve & Credit</button>'+
|
||
'<button class="btn btn-red" data-pid="'+p.id+'" onclick="resolvePurchase(this.dataset.pid,"failed")" style="width:100%;font-size:15px;padding:10px">✗ Reject</button></div>'
|
||
: (p.admin_note ? '<div style="font-size:14px;color:var(--gold);padding:6px 8px;background:rgba(240,192,64,.07);border-radius:6px;margin-top:4px">📝 '+escHtmlA(p.admin_note)+'</div>' : '');
|
||
|
||
return '<div class="card" style="margin-bottom:10px;'+(isPending?'border-color:rgba(240,192,64,.25);background:rgba(240,192,64,.02)':'')+'">'+
|
||
'<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">'+
|
||
'<div style="flex:1;min-width:180px">'+
|
||
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px">'+
|
||
'<span style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:15px">'+escHtmlA(p.username)+'</span>'+
|
||
'<span style="font-size:15px;color:var(--text2)">#'+p.id+'</span>'+
|
||
'<span style="font-size:15px;font-weight:700;color:'+statusColor+';background:rgba(0,0,0,.25);border:1px solid '+statusColor+';border-radius:6px;padding:2px 8px">'+statusLabel+'</span></div>'+
|
||
'<div style="font-size:14px;color:var(--text2)">'+escHtmlA(p.alias||'')+' · '+mIcon+' '+mLabel+' · '+date+'</div>'+
|
||
platformRow+
|
||
'<div style="font-family:\'Exo 2\',sans-serif;font-weight:900;font-size:24px;color:var(--gold);margin-top:6px">'+p.tokens+' 🪙</div>'+
|
||
'<div style="font-size:14px;font-weight:700;color:var(--green)">$'+amt+'</div>'+
|
||
(isManual?'<div style="margin-top:8px;background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:6px;padding:6px 10px;font-size:14px"><span style="color:var(--cyan);font-weight:700">Manual Payment</span> — verify before approving</div>':'')+
|
||
'</div>'+actions+'</div></div>';
|
||
}
|
||
|
||
|
||
async function loadPurchases(status, btn=null) {
|
||
if (btn) { document.querySelectorAll('#section-purchases .ftab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); }
|
||
const d = await apiFetch('purchases&status='+status);
|
||
const el = document.getElementById('purchases-list');
|
||
if (!d.success || !d.purchases.length) { el.innerHTML='<div class="empty">No purchases found.</div>'; return; }
|
||
el.innerHTML = d.purchases.map(p => purchaseCard(p, true)).join('');
|
||
}
|
||
|
||
async function loadDashPurchases() {
|
||
const d = await apiFetch('purchases&status=pending');
|
||
const el = document.getElementById('dash-purchases');
|
||
if (!d.success || !d.purchases.length) { el.innerHTML='<div class="empty">No pending purchases 🎉</div>'; return; }
|
||
el.innerHTML = d.purchases.map(p => purchaseCard(p, true)).join('');
|
||
}
|
||
|
||
async function resolvePurchase(id, status) {
|
||
const note = (document.getElementById('pnote-'+id)?.value||'').trim();
|
||
const d = await apiFetch('resolve_purchase','POST',{id,status,note});
|
||
if (d.success) {
|
||
toast(status==='completed' ? '✓ Tokens credited!' : '✗ Purchase rejected', status==='completed'?'ok':'err');
|
||
document.getElementById('pr-'+id)?.remove();
|
||
loadStats();
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// ─── CASHOUTS ──────────────────────────────────────────────
|
||
const PAYOUT_ICONS_A = { venmo:'💙', cashapp:'💚', zelle:'💜', chime:'🟢', bank:'🏦', other:'💰' };
|
||
const PAYOUT_LABELS_A = { venmo:'Venmo', cashapp:'Cash App', zelle:'Zelle', chime:'Chime', bank:'Bank Transfer', other:'Other' };
|
||
|
||
function cashoutRow(c, showActions=true) {
|
||
const payoutInfo = c.payout_handle
|
||
? `<div style="margin-top:6px;background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:6px;padding:6px 10px;font-size:14px">
|
||
<span style="font-weight:700;color:var(--cyan)">${PAYOUT_ICONS_A[c.payout_method_type]||'💰'} ${PAYOUT_LABELS_A[c.payout_method_type]||c.payout_method_type}:</span>
|
||
<span style="color:var(--text);margin-left:4px">${escHtmlA(c.payout_handle)}</span>
|
||
</div>`
|
||
: '<div style="font-size:15px;color:var(--red);margin-top:4px">⚠️ No payout method on file</div>';
|
||
|
||
const statusColors = { pending:'var(--text2)', locked:'var(--cyan)', approved:'var(--green)', sent:'var(--green)', rejected:'var(--red)', deleted:'var(--text2)' };
|
||
const statusLabels = { pending:'⏳ Draft', locked:'🔒 Ready to Process', sent:'✅ Sent', approved:'✅ Sent', rejected:'❌ Denied', deleted:'🗑 Deleted' };
|
||
const statusColor = statusColors[c.status] || 'var(--text2)';
|
||
const canAct = showActions && (c.status === 'pending' || c.status === 'locked');
|
||
const isLocked = c.status === 'locked';
|
||
const playerAlias = c.user_alias || c.alias || c.username || '';
|
||
|
||
return `<div class="card" style="margin-bottom:10px;${isLocked?'border-color:rgba(0,229,255,.25);background:rgba(0,229,255,.02)':''}">
|
||
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:180px">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px">
|
||
<span style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px">${escHtmlA(c.username)}</span>
|
||
<span style="font-size:15px;color:var(--text2)">#${c.id}</span>
|
||
<span style="font-size:15px;font-weight:700;color:${statusColor};background:rgba(0,0,0,.25);border:1px solid ${statusColor};border-radius:6px;padding:2px 8px">${statusLabels[c.status]||c.status}</span>
|
||
</div>
|
||
<div style="font-size:14px;color:var(--text2)">${pname(c.platform_id)} · <span style="color:var(--cyan)">${escHtmlA(c.alias||'')}</span> · <span style="color:var(--text2)">${escHtmlA(playerAlias)}</span></div>
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:900;font-size:24px;color:var(--gold);margin-top:4px">${c.tokens} 🪙</div>
|
||
<div style="font-size:15px;color:var(--text2);margin-top:2px">${(c.created_at||'').substring(0,16)}</div>
|
||
${payoutInfo}
|
||
${c.admin_note ? `<div style="font-size:14px;color:var(--gold);margin-top:6px;padding:6px 8px;background:rgba(240,192,64,.07);border-radius:6px">📝 ${escHtmlA(c.admin_note)}</div>` : ''}
|
||
</div>
|
||
${canAct ? `
|
||
<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;min-width:160px">
|
||
<button class="btn btn-gold" onclick="openPayoutModal(${c.id})" style="width:100%;font-size:15px;padding:10px">
|
||
💰 Process Payout
|
||
</button>
|
||
<button class="btn btn-red" onclick="resolveCashout(${c.id},'rejected')" style="width:100%;font-size:14px;padding:8px">
|
||
✗ Deny
|
||
</button>
|
||
<button onclick="resolveCashout(${c.id},'deleted')" style="width:100%;background:rgba(255,255,255,.04);border:1px solid var(--border);color:var(--text2);border-radius:8px;padding:8px;font-size:14px;cursor:pointer;font-family:'Exo 2',sans-serif;font-weight:700">
|
||
🗑 Delete
|
||
</button>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function loadCashouts(status, btn=null) {
|
||
if (btn) { document.querySelectorAll('#section-cashouts .ftab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); }
|
||
const d = await apiFetch('cashouts&status=' + status);
|
||
const el = document.getElementById('cashouts-table');
|
||
if (!d.success || !d.cashouts.length) { el.innerHTML = '<div class="empty" style="padding:24px;text-align:center">No cashouts found.</div>'; return; }
|
||
el.innerHTML = d.cashouts.map(c => cashoutRow(c, true)).join('');
|
||
}
|
||
|
||
async function loadDashCashouts() {
|
||
const d = await apiFetch('cashouts&status=pending');
|
||
const el = document.getElementById('dash-cashouts');
|
||
if (!d.success || !d.cashouts.length) { el.innerHTML = '<div class="empty">No pending cashouts 🎉</div>'; return; }
|
||
el.innerHTML = d.cashouts.map(c => cashoutRow(c, true)).join('');
|
||
}
|
||
|
||
async function resolveCashout(id, status) {
|
||
const labels = { approved:'✓ Payment Sent', rejected:'✗ Cashout Denied', deleted:'🗑 Request Deleted' };
|
||
const confirmMsgs = {
|
||
approved: 'Mark this cashout as SENT? This confirms you have sent the payment to the player.',
|
||
rejected: 'Deny this cashout request? Tokens will be returned to the player.',
|
||
deleted: 'Delete this cashout request?'
|
||
};
|
||
if (!confirm(confirmMsgs[status]||'Are you sure?')) return;
|
||
const note = (document.getElementById('cnote-'+id)?.value||'').trim();
|
||
const d = await apiFetch('resolve_cashout','POST',{id,status,note});
|
||
if (d.success) {
|
||
toast(labels[status]||'Done', status==='approved'?'ok':'err');
|
||
loadCashouts('pending');
|
||
loadDashCashouts();
|
||
loadStats();
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// ─── USERS ─────────────────────────────────────────────────
|
||
// ─── GAMER MANAGEMENT ──────────────────────────────────────
|
||
let GM = { all: [], filtered: [], filter: 'all', current: null, chatLastId: 0, chatPoll: null };
|
||
|
||
const AVATAR_COLORS = ['#9b5de5','#e63946','#457b9d','#2ec4b6','#f4a261','#7b2fbe','#ff6b35','#00b4d8'];
|
||
function avatarColor(username) { let h=0; for(let c of username) h=(h*31+c.charCodeAt(0))%AVATAR_COLORS.length; return AVATAR_COLORS[h]; }
|
||
function avatarInit(username) { return (username||'?').charAt(0).toUpperCase(); }
|
||
|
||
async function loadUsers() {
|
||
const d = await apiFetch('users');
|
||
if (!d.success) return;
|
||
GM.all = d.users;
|
||
applyGamerFilter();
|
||
}
|
||
|
||
function setGamerFilter(f, btn) {
|
||
GM.filter = f;
|
||
document.querySelectorAll('.gm-filter').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
applyGamerFilter();
|
||
}
|
||
|
||
function filterGamers(q) {
|
||
const base = GM.filter === 'all' ? GM.all
|
||
: GM.filter === 'unverified' ? GM.all.filter(u => !parseInt(u.email_verified))
|
||
: GM.all.filter(u => u.status === GM.filter);
|
||
if (!q) { GM.filtered = base; }
|
||
else {
|
||
const lq = q.toLowerCase();
|
||
GM.filtered = base.filter(u =>
|
||
u.username.toLowerCase().includes(lq) ||
|
||
u.alias.toLowerCase().includes(lq) ||
|
||
(u.email||'').toLowerCase().includes(lq)
|
||
);
|
||
}
|
||
renderGamerGrid();
|
||
}
|
||
|
||
function applyGamerFilter() {
|
||
const q = document.getElementById('gm-search').value;
|
||
filterGamers(q);
|
||
}
|
||
|
||
function renderGamerGrid() {
|
||
const el = document.getElementById('gm-grid');
|
||
const users = GM.filtered;
|
||
|
||
// Mini stats
|
||
const total = GM.all.length;
|
||
const active = GM.all.filter(u=>u.status==='active'&&parseInt(u.email_verified)).length;
|
||
const susp = GM.all.filter(u=>u.status==='suspended').length;
|
||
const unver = GM.all.filter(u=>!parseInt(u.email_verified)).length;
|
||
document.getElementById('gm-stats-mini').innerHTML =
|
||
`<span style="color:var(--green)">${active} active</span> · <span style="color:var(--red)">${susp} suspended</span> · <span style="color:var(--yellow)">${unver} unverified</span> · ${total} total`;
|
||
|
||
if (!users.length) {
|
||
el.innerHTML = '<div class="gm-empty">No players match this filter.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = users.map(u => {
|
||
const color = avatarColor(u.username);
|
||
const init = avatarInit(u.username);
|
||
const verified = parseInt(u.email_verified);
|
||
const isSupended = u.status === 'suspended';
|
||
const cardClass = isSupended ? 'suspended' : (!verified ? 'unverified' : '');
|
||
const lastSeen = u.last_login ? timeAgo(u.last_login) : 'Never';
|
||
return `
|
||
<div class="gm-card ${cardClass}" onclick="openGamer(${u.id})">
|
||
<div class="gm-card-top">
|
||
<div class="gm-card-avatar" style="background:linear-gradient(135deg,${color},${color}aa)">${init}</div>
|
||
<div class="gm-card-info">
|
||
<div class="gm-card-name">${escHtmlA(u.username)}</div>
|
||
<div class="gm-card-alias">${escHtmlA(u.alias)}</div>
|
||
<div class="gm-card-badges">
|
||
<span class="badge b-${u.status}">${u.status}</span>
|
||
${verified ? '<span class="badge" style="background:rgba(0,230,118,.12);color:var(--green)">✓ verified</span>' : '<span class="badge b-pending">⚠ unverified</span>'}
|
||
${u.is_admin ? '<span class="badge" style="background:rgba(240,192,64,.15);color:var(--gold)">admin</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gm-card-stats">
|
||
<div class="gm-stat"><div class="gm-stat-val">${parseFloat(u.tokens||0)}</div><div class="gm-stat-lbl">TOKENS</div></div>
|
||
<div class="gm-stat"><div class="gm-stat-val" style="font-size:14px;color:var(--text2)">${(u.created_at||'').substring(0,10)}</div><div class="gm-stat-lbl">JOINED</div></div>
|
||
<div class="gm-stat"><div class="gm-stat-val" style="font-size:14px;color:var(--text2)">${lastSeen}</div><div class="gm-stat-lbl">LAST SEEN</div></div>
|
||
</div>
|
||
<div class="gm-card-email">${escHtmlA(u.email||'No email')}</div>
|
||
<div class="gm-card-hover-cta">VIEW PLAYER PROFILE →</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function openGamer(uid) {
|
||
const d = await apiFetch('user_detail&user_id=' + uid);
|
||
if (!d.success) { toast('Failed to load player', 'err'); return; }
|
||
GM.current = d.user;
|
||
GM.chatLastId = 0;
|
||
|
||
document.getElementById('gm-list-view').style.display = 'none';
|
||
document.getElementById('gm-detail-view').style.display = 'block';
|
||
|
||
renderGamerProfile(d.user);
|
||
renderGamerOverview(d.user, d.stats);
|
||
populateGamerEdit(d.user);
|
||
updateAdminToggleBtn();
|
||
loadGamerPurchases(uid);
|
||
loadGamerCashouts(uid);
|
||
loadGamerChat(uid, true);
|
||
|
||
// Switch to overview tab
|
||
switchGamerTab('overview', document.querySelector('.gm-tab'));
|
||
|
||
// Poll chat while detail open
|
||
clearInterval(GM.chatPoll);
|
||
GM.chatPoll = setInterval(() => {
|
||
if (GM.current && document.getElementById('gtab-chat').classList.contains('active')) {
|
||
loadGamerChat(GM.current.id, false);
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
function renderGamerProfile(u) {
|
||
const color = avatarColor(u.username);
|
||
const verified = parseInt(u.email_verified);
|
||
document.getElementById('gm-profile-card').innerHTML = `
|
||
<div class="gm-profile-avatar" style="background:linear-gradient(135deg,${color},${color}99)">${avatarInit(u.username)}</div>
|
||
<div class="gm-profile-info">
|
||
<div class="gm-profile-name">${escHtmlA(u.username)}</div>
|
||
<div class="gm-profile-alias">🎮 ${escHtmlA(u.alias)}</div>
|
||
<div class="gm-profile-email">📧 ${escHtmlA(u.email||'No email')}</div>
|
||
<div style="font-size:15px;color:var(--text2);margin-top:4px">
|
||
Joined ${(u.created_at||'').substring(0,10)} ·
|
||
Last login: ${u.last_login ? timeAgo(u.last_login) : 'Never'}
|
||
</div>
|
||
</div>
|
||
<div class="gm-profile-right">
|
||
<div class="gm-profile-tokens">${parseFloat(u.tokens||0)}</div>
|
||
<div class="gm-profile-tok-lbl">TOKENS</div>
|
||
<div class="gm-profile-badges">
|
||
<span class="badge b-${u.status}">${u.status}</span>
|
||
${verified ? '<span class="badge" style="background:rgba(0,230,118,.12);color:var(--green)">✓ verified</span>' : '<span class="badge b-pending">unverified</span>'}
|
||
${u.is_admin ? '<span class="badge" style="background:rgba(240,192,64,.15);color:var(--gold)">admin</span>' : ''}
|
||
</div>
|
||
</div>`;
|
||
// Update token display in tokens tab
|
||
document.getElementById('gm-token-display').textContent = parseFloat(u.tokens||0) + ' 🪙';
|
||
// Update suspend button
|
||
const sb = document.getElementById('gm-suspend-btn');
|
||
if (u.status === 'active') {
|
||
sb.textContent = '🔒 SUSPEND ACCOUNT (Lock Login)';
|
||
sb.className = 'btn btn-red';
|
||
} else {
|
||
sb.textContent = '🔓 ACTIVATE ACCOUNT (Restore Login)';
|
||
sb.className = 'btn btn-green';
|
||
}
|
||
}
|
||
|
||
function renderGamerOverview(u, stats) {
|
||
const verified = parseInt(u.email_verified);
|
||
document.getElementById('gm-info-grid').innerHTML = [
|
||
{ label: 'User ID', val: '#' + u.id },
|
||
{ label: 'Username', val: u.username },
|
||
{ label: 'Alias', val: u.alias },
|
||
{ label: 'Email', val: u.email || '—' },
|
||
{ label: 'Status', val: `<span class="badge b-${u.status}">${u.status}</span>` },
|
||
{ label: 'Email Verified',val: verified ? '<span style="color:var(--green)">✓ Yes</span>' : '<span style="color:var(--yellow)">⚠ No</span>' },
|
||
{ label: 'Token Balance', val: `<span style="color:var(--gold);font-family:\'Exo 2\',sans-serif;font-weight:700">${parseFloat(u.tokens||0)} 🪙</span>` },
|
||
{ label: 'Total Spent', val: stats ? `<span style="color:var(--green);font-weight:700">$${parseFloat(stats.total_spent||0).toFixed(2)}</span>` : '—' },
|
||
{ label: 'Tokens Purchased', val: stats ? `<span style="color:var(--gold)">${stats.total_tokens_bought} 🪙</span>` : '—' },
|
||
{ label: 'Completed Purchases', val: stats ? `<span style="color:var(--green)">${stats.completed_purchases}</span>` : '—' },
|
||
{ label: 'Pending Payments', val: stats ? `<span style="color:var(--yellow)">${stats.pending_purchases}</span>` : '—' },
|
||
{ label: 'Failed Payments', val: stats ? `<span style="color:var(--red)">${stats.failed_purchases}</span>` : '—' },
|
||
{ label: 'Cashout Requests', val: stats ? stats.total_cashouts + ' total' : '—' },
|
||
{ label: 'Joined', val: (u.created_at||'').substring(0,16) },
|
||
{ label: 'Last Login', val: u.last_login ? u.last_login.substring(0,16) : 'Never' },
|
||
{ label: 'Admin Access', val: u.is_admin ? '<span style="color:var(--gold)">Yes</span>' : 'No' },
|
||
].map(item => `
|
||
<div class="gm-info-item">
|
||
<div class="gm-info-label">${item.label}</div>
|
||
<div class="gm-info-val">${item.val}</div>
|
||
</div>`).join('');
|
||
|
||
document.getElementById('gm-action-btns').innerHTML = `
|
||
<button class="btn btn-gold" style="flex:1;min-width:140px" onclick="switchGamerTab('tokens',document.querySelectorAll('.gm-tab')[1])">🪙 Manage Tokens</button>
|
||
<button class="btn btn-cyan" style="flex:1;min-width:140px" onclick="switchGamerTab('chat',document.querySelectorAll('.gm-tab')[4])">💬 Message Player</button>
|
||
<button class="btn" style="flex:1;min-width:140px;background:rgba(155,93,229,.12);color:#c77dff;border:1px solid rgba(155,93,229,.3)" onclick="switchGamerTab('edit',document.querySelectorAll('.gm-tab')[5])">✏️ Edit Account</button>
|
||
${!parseInt(u.email_verified) ? '<button class="btn btn-gold" style="flex:1;min-width:140px" onclick="gmResendVerify()">📧 Resend Verify Email</button>' : ''}`;
|
||
}
|
||
|
||
function populateGamerEdit(u) {
|
||
document.getElementById('gm-edit-username').value = u.username || '';
|
||
document.getElementById('gm-edit-alias').value = u.alias || '';
|
||
document.getElementById('gm-edit-email').value = u.email || '';
|
||
document.getElementById('gm-edit-password').value = '';
|
||
}
|
||
|
||
async function loadGamerPurchases(uid) {
|
||
const d = await apiFetch('user_purchases&user_id=' + uid);
|
||
const el = document.getElementById('gm-purchases-table');
|
||
if (!d.success || !d.purchases.length) { el.innerHTML = '<div class="gm-empty">No purchases found.</div>'; return; }
|
||
|
||
// Summary strip
|
||
const total = d.purchases.length;
|
||
const completed = d.purchases.filter(p=>p.status==='completed').length;
|
||
const pending = d.purchases.filter(p=>p.status==='pending').length;
|
||
const failed = d.purchases.filter(p=>p.status==='failed').length;
|
||
const revenue = d.purchases.filter(p=>p.status==='completed').reduce((s,p)=>s+(p.amount_cents/100),0);
|
||
const tokens = d.purchases.filter(p=>p.status==='completed').reduce((s,p)=>s+parseInt(p.tokens),0);
|
||
|
||
el.innerHTML = `
|
||
<div class="pm-summary">
|
||
<div class="pm-sum-item"><div class="pm-sum-val" style="color:var(--green)">$${revenue.toFixed(2)}</div><div class="pm-sum-lbl">Revenue</div></div>
|
||
<div class="pm-sum-item"><div class="pm-sum-val" style="color:var(--gold)">${tokens} 🪙</div><div class="pm-sum-lbl">Tokens Sold</div></div>
|
||
<div class="pm-sum-item"><div class="pm-sum-val" style="color:var(--green)">${completed}</div><div class="pm-sum-lbl">Completed</div></div>
|
||
<div class="pm-sum-item"><div class="pm-sum-val" style="color:var(--yellow)">${pending}</div><div class="pm-sum-lbl">Pending</div></div>
|
||
<div class="pm-sum-item"><div class="pm-sum-val" style="color:var(--red)">${failed}</div><div class="pm-sum-lbl">Failed</div></div>
|
||
</div>
|
||
${d.purchases.map(p => {
|
||
const isCustom = parseInt(p.is_custom);
|
||
const isPending = p.status === 'pending';
|
||
const isManual = p.payment_method !== 'card';
|
||
const billing = [p.billing_name, p.billing_address, [p.billing_city, p.billing_state, p.billing_zip].filter(Boolean).join(' ')].filter(Boolean).join(' · ');
|
||
return `
|
||
<div class="pm-card pm-${p.status}">
|
||
<div class="pm-card-top">
|
||
<div class="pm-card-left">
|
||
<div class="pm-order-id">#${p.id} ${isCustom ? '<span class="pm-custom-badge">CUSTOM</span>' : ''}</div>
|
||
<div class="pm-date">${(p.created_at||'').substring(0,16)}</div>
|
||
</div>
|
||
<div class="pm-card-right">
|
||
<div class="pm-amount">$${(p.amount_cents/100).toFixed(2)}</div>
|
||
<div class="pm-tokens">${p.tokens} 🪙</div>
|
||
</div>
|
||
</div>
|
||
<div class="pm-card-mid">
|
||
<div class="pm-detail-row">
|
||
<span class="badge ${methodClass(p.payment_method)}">${methodLabel(p.payment_method)}</span>
|
||
<span class="badge b-${p.status}">${p.status}</span>
|
||
${p.card_brand ? `<span class="pm-card-info">💳 ${p.card_brand} ····${p.card_last4||''}</span>` : ''}
|
||
</div>
|
||
<div class="pm-detail-row" style="margin-top:6px">
|
||
<span class="pm-lbl">Platform:</span> <span class="pm-val">${pname(p.platform_id)}</span>
|
||
·
|
||
<span class="pm-lbl">Alias:</span> <span class="pm-val" style="color:var(--cyan)">${escHtmlA(p.game_alias||'—')}</span>
|
||
</div>
|
||
${billing ? `<div class="pm-detail-row" style="margin-top:4px"><span class="pm-lbl">Billing:</span> <span class="pm-val">${escHtmlA(billing)}</span></div>` : ''}
|
||
${p.billing_email ? `<div class="pm-detail-row"><span class="pm-lbl">Email:</span> <span class="pm-val" style="color:var(--cyan)">${escHtmlA(p.billing_email)}</span></div>` : ''}
|
||
${p.square_payment_id ? `<div class="pm-detail-row" style="margin-top:4px"><span class="pm-lbl">Square ID:</span> <span class="pm-val" style="font-family:monospace;font-size:15px;color:var(--text2)">${p.square_payment_id}</span></div>` : ''}
|
||
${p.failure_reason ? `<div class="pm-failure">⚠️ ${escHtmlA(p.failure_reason)}</div>` : ''}
|
||
${p.admin_note ? `<div class="pm-admin-note">📝 Admin: ${escHtmlA(p.admin_note)}</div>` : ''}
|
||
${p.receipt_url ? `<div style="margin-top:6px"><a href="${p.receipt_url}" target="_blank" class="pm-receipt-link">🧾 View Receipt</a></div>` : ''}
|
||
</div>
|
||
${isPending && isManual ? `
|
||
<div class="pm-actions">
|
||
<input type="text" class="fi-sm" id="pnote-${p.id}" placeholder="Admin note..." style="flex:1">
|
||
<button class="btn btn-green btn-sm" onclick="resolveGamerPurchase(${p.id},'completed',${uid})">✓ Approve</button>
|
||
<button class="btn btn-red btn-sm" onclick="resolveGamerPurchase(${p.id},'failed',${uid})">✗ Reject</button>
|
||
</div>` : ''}
|
||
</div>`
|
||
}).join('')}`;
|
||
}
|
||
|
||
async function resolveGamerPurchase(id, status, uid) {
|
||
const note = (document.getElementById('pnote-'+id)?.value||'').trim();
|
||
const d = await apiFetch('resolve_purchase','POST',{id,status,note});
|
||
if (d.success) {
|
||
toast(status==='completed'?'✓ Tokens credited!':'✗ Purchase rejected', status==='completed'?'ok':'err');
|
||
loadGamerPurchases(uid);
|
||
// Refresh profile token display
|
||
const uData = await apiFetch('user_detail&user_id='+uid);
|
||
if (uData.success) { GM.current = uData.user; renderGamerProfile(uData.user); }
|
||
loadStats();
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function loadGamerCashouts(uid) {
|
||
const d = await apiFetch('user_cashouts&user_id=' + uid);
|
||
const el = document.getElementById('gm-cashouts-table');
|
||
if (!d.success || !d.cashouts.length) { el.innerHTML = '<div class="gm-empty">No cashouts found.</div>'; return; }
|
||
el.innerHTML = `<table>
|
||
<thead><tr><th>#</th><th>Platform</th><th>Alias</th><th>Tokens</th><th>Status</th><th>Date</th></tr></thead>
|
||
<tbody>${d.cashouts.map(c => `<tr>
|
||
<td style="color:var(--text2)">#${c.id}</td>
|
||
<td>${pname(c.platform_id)}</td>
|
||
<td style="color:var(--cyan)">${escHtmlA(c.alias)}</td>
|
||
<td><strong style="color:var(--gold)">${c.tokens} 🪙</strong></td>
|
||
<td><span class="badge b-${c.status}">${c.status}</span></td>
|
||
<td style="color:var(--text2);font-size:15px">${(c.created_at||'').substring(0,16)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
async function loadGamerChat(uid, scrollToBottom) {
|
||
const d = await apiFetch(`chat_thread&user_id=${uid}&since=${GM.chatLastId}`);
|
||
if (!d.success) return;
|
||
const container = document.getElementById('gm-chat-messages');
|
||
if (d.messages.length === 0 && GM.chatLastId === 0) {
|
||
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text2);font-size:15px">No messages yet with this player.</div>';
|
||
return;
|
||
}
|
||
if (d.messages.length > 0) {
|
||
if (GM.chatLastId === 0) container.innerHTML = '';
|
||
let lastDate = '';
|
||
d.messages.forEach(msg => {
|
||
const msgDate = msg.created_at.substring(0,10);
|
||
if (msgDate !== lastDate) {
|
||
lastDate = msgDate;
|
||
const div = document.createElement('div');
|
||
div.className = 'admin-date-div';
|
||
div.textContent = formatAdminDateLabel(msgDate);
|
||
container.appendChild(div);
|
||
}
|
||
container.appendChild(buildAdminBubble(msg));
|
||
GM.chatLastId = Math.max(GM.chatLastId, parseInt(msg.id));
|
||
});
|
||
if (scrollToBottom || (container.scrollHeight - container.scrollTop - container.clientHeight < 120))
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
}
|
||
|
||
async function gmClearThread() {
|
||
if (!GM.current) return;
|
||
if (!confirm('Clear all chat messages with ' + GM.current.username + '?\n\nThis cannot be undone.')) return;
|
||
const d = await apiFetch('chat_clear_thread', 'POST', { user_id: GM.current.id });
|
||
if (d.success) {
|
||
document.getElementById('gm-chat-messages').innerHTML =
|
||
'<div style="color:var(--text2);font-size:15px;text-align:center;padding:24px">Conversation cleared.</div>';
|
||
GM.chatLastId = 0;
|
||
toast('Conversation cleared', 'ok');
|
||
loadChatBadge();
|
||
} else toast(d.error || 'Error', 'err');
|
||
}
|
||
|
||
async function gmSendMsg() {
|
||
const input = document.getElementById('gm-chat-input');
|
||
const msg = input.value.trim();
|
||
if (!msg || !GM.current) return;
|
||
input.value = '';
|
||
const container = document.getElementById('gm-chat-messages');
|
||
const temp = buildAdminBubble({ id:'tmp', sender:'admin', message:msg, created_at:new Date().toISOString().replace('T',' ').substring(0,19) });
|
||
temp.style.opacity = '0.6';
|
||
container.appendChild(temp);
|
||
container.scrollTop = container.scrollHeight;
|
||
const d = await apiFetch('chat_admin_send','POST',{user_id:GM.current.id, message:msg});
|
||
if (d.success) { temp.style.opacity='1'; GM.chatLastId = Math.max(GM.chatLastId, d.id); }
|
||
else { temp.remove(); toast('Send failed','err'); }
|
||
}
|
||
|
||
async function gmAdjustTokens(sign) {
|
||
const amt = parseFloat(document.getElementById('gm-tok-amount').value);
|
||
const reason = document.getElementById('gm-tok-reason').value.trim();
|
||
const msgEl = document.getElementById('gm-tok-msg');
|
||
if (isNaN(amt) || amt <= 0) { showAdminAlert(msgEl,'Enter a valid positive amount.','error'); return; }
|
||
const d = await apiFetch('adjust_tokens','POST',{user_id:GM.current.id, amount: sign * amt, reason});
|
||
if (d.success) {
|
||
GM.current.tokens = d.new_balance;
|
||
document.getElementById('gm-token-display').textContent = d.new_balance + ' 🪙';
|
||
document.getElementById('gm-profile-tokens') && (document.querySelector('.gm-profile-tokens') ? document.querySelector('.gm-profile-tokens').textContent = d.new_balance : null);
|
||
renderGamerProfile(GM.current);
|
||
showAdminAlert(msgEl, `Balance updated: ${d.new_balance} tokens`, 'success');
|
||
document.getElementById('gm-tok-amount').value = '';
|
||
loadUsers();
|
||
} else showAdminAlert(msgEl, d.error||'Error','error');
|
||
}
|
||
|
||
async function gmSetTokens() {
|
||
const newBal = parseFloat(document.getElementById('gm-tok-set').value);
|
||
const msgEl = document.getElementById('gm-tok-msg');
|
||
if (isNaN(newBal) || newBal < 0) { showAdminAlert(msgEl,'Enter a valid balance.','error'); return; }
|
||
const d = await apiFetch('set_tokens','POST',{user_id:GM.current.id, balance:newBal});
|
||
if (d.success) {
|
||
GM.current.tokens = d.new_balance;
|
||
document.getElementById('gm-token-display').textContent = d.new_balance + ' 🪙';
|
||
renderGamerProfile(GM.current);
|
||
showAdminAlert(msgEl, `Balance set to ${d.new_balance} tokens`, 'success');
|
||
document.getElementById('gm-tok-set').value = '';
|
||
loadUsers();
|
||
} else showAdminAlert(msgEl, d.error||'Error','error');
|
||
}
|
||
|
||
async function gmSaveEdit() {
|
||
const al = document.getElementById('gm-edit-alert');
|
||
const username = document.getElementById('gm-edit-username').value.trim();
|
||
const alias = document.getElementById('gm-edit-alias').value.trim();
|
||
const email = document.getElementById('gm-edit-email').value.trim();
|
||
const password = document.getElementById('gm-edit-password').value;
|
||
if (!username||!alias) { showAdminAlert(al,'Username and alias are required.','error'); return; }
|
||
const d = await apiFetch('edit_user','POST',{user_id:GM.current.id, username, alias, email, password});
|
||
if (d.success) {
|
||
GM.current = {...GM.current, username, alias, email};
|
||
renderGamerProfile(GM.current);
|
||
showAdminAlert(al,'Player info updated!','success');
|
||
loadUsers();
|
||
toast('Saved!','ok');
|
||
} else showAdminAlert(al, d.error||'Error saving.','error');
|
||
}
|
||
|
||
async function gmToggleSuspend() {
|
||
const isSuspended = GM.current.status === 'suspended';
|
||
const action = isSuspended ? 'activate' : 'suspend';
|
||
if (!isSuspended && !confirm(`Suspend ${GM.current.username}? They will be locked out immediately.`)) return;
|
||
const d = await apiFetch('toggle_user','POST',{user_id:GM.current.id});
|
||
if (d.success) {
|
||
GM.current.status = isSuspended ? 'active' : 'suspended';
|
||
renderGamerProfile(GM.current);
|
||
renderGamerOverview(GM.current, null);
|
||
toast(`Account ${isSuspended?'activated':'suspended'}`, 'ok');
|
||
loadUsers();
|
||
} else toast('Error','err');
|
||
}
|
||
|
||
async function gmSendPasswordReset() {
|
||
if (!GM.current.email) { toast('No email on file for this player','err'); return; }
|
||
const d = await apiFetch('send_password_reset','POST',{user_id:GM.current.id});
|
||
if (d.success) toast('Password reset email sent!','ok');
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function gmResendVerify() {
|
||
if (!GM.current.email) { toast('No email on file','err'); return; }
|
||
const d = await apiFetch('resend_verification','POST',{user_id:GM.current.id});
|
||
if (d.success) toast('Verification email sent!','ok');
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function gmToggleAdmin() {
|
||
const u = GM.current;
|
||
if (!u) return;
|
||
const making = !parseInt(u.is_admin);
|
||
const msg = making
|
||
? 'Grant ADMIN access to ' + u.username + '? They will have full admin panel access.'
|
||
: 'Remove admin access from ' + u.username + '?';
|
||
if (!confirm(msg)) return;
|
||
const d = await apiFetch('toggle_admin','POST',{user_id: u.id});
|
||
if (d.success) {
|
||
GM.current.is_admin = d.is_admin;
|
||
const msg = d.is_admin ? u.username+' is now an ADMIN' : 'Admin removed from '+u.username;
|
||
toast(msg + ' — they must log out and back in for changes to take effect', 'ok');
|
||
renderGamerProfile(GM.current);
|
||
renderGamerOverview(GM.current, null);
|
||
updateAdminToggleBtn();
|
||
loadUsers();
|
||
} else {
|
||
toast(d.error || 'Error', 'err');
|
||
}
|
||
}
|
||
|
||
function updateAdminToggleBtn() {
|
||
const u = GM.current;
|
||
const btn = document.getElementById('gm-admin-toggle-btn');
|
||
if (!btn || !u) return;
|
||
// Only master admin can see/use this button
|
||
if (!IS_MASTER_ADMIN) { btn.style.display = 'none'; return; }
|
||
btn.style.display = 'block';
|
||
const isMaster = u.id == MASTER_ADMIN_ID;
|
||
if (isMaster) {
|
||
btn.textContent = '🔒 Master Admin (Locked)';
|
||
btn.disabled = true;
|
||
btn.className = 'btn';
|
||
btn.style.cssText = 'background:rgba(240,192,64,.08);color:var(--gold);border:1px solid rgba(240,192,64,.2);cursor:not-allowed;opacity:.7';
|
||
} else if (parseInt(u.is_admin)) {
|
||
btn.textContent = '⬇️ Remove Admin Access';
|
||
btn.disabled = false;
|
||
btn.className = 'btn btn-red';
|
||
btn.style.cssText = '';
|
||
} else {
|
||
btn.textContent = '⬆️ Grant Admin Access';
|
||
btn.disabled = false;
|
||
btn.className = 'btn btn-gold';
|
||
btn.style.cssText = '';
|
||
}
|
||
}
|
||
|
||
async function gmDeleteAccount() {
|
||
if (!confirm(`⚠️ PERMANENTLY delete ${GM.current.username}?\n\nThis removes all their data and cannot be undone.`)) return;
|
||
if (!confirm(`Are you absolutely sure? Type OK to confirm deletion of ${GM.current.username}.`)) return;
|
||
const d = await apiFetch('delete_user','POST',{user_id:GM.current.id});
|
||
if (d.success) {
|
||
toast(`${GM.current.username} deleted`,'ok');
|
||
clearInterval(GM.chatPoll);
|
||
showGamerList();
|
||
loadUsers();
|
||
loadStats();
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
function showGamerList() {
|
||
clearInterval(GM.chatPoll);
|
||
GM.current = null;
|
||
document.getElementById('gm-list-view').style.display = 'block';
|
||
document.getElementById('gm-detail-view').style.display = 'none';
|
||
}
|
||
|
||
function switchGamerTab(name, btn) {
|
||
document.querySelectorAll('.gm-tab').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.gm-tab-panel').forEach(p => p.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
document.getElementById('gtab-'+name)?.classList.add('active');
|
||
if (name === 'chat' && GM.current) loadGamerChat(GM.current.id, true);
|
||
if (name === 'billing' && GM.current) loadGamerBilling(GM.current.id);
|
||
if (name === 'aliases' && GM.current) gmLoadAliases(GM.current.id);
|
||
if (name === 'payout' && GM.current) gmLoadPayout(GM.current.id);
|
||
if (name === 'platforms' && GM.current) gmLoadPlatformAccounts(GM.current.id);
|
||
if (name === 'platforms' && GM.current) gmLoadPlatformAccounts(GM.current.id);
|
||
}
|
||
|
||
function showAdminAlert(el, msg, type) {
|
||
el.textContent = msg;
|
||
el.className = `alert show alert-${type}`;
|
||
setTimeout(() => el.className = 'alert', 4000);
|
||
}
|
||
|
||
function timeAgo(dtStr) {
|
||
const d = new Date(dtStr.replace(' ','T'));
|
||
const diff = (Date.now() - d) / 1000;
|
||
if (diff < 60) return 'Just now';
|
||
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
|
||
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
|
||
if (diff < 604800) return Math.floor(diff/86400) + 'd ago';
|
||
return d.toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
||
}
|
||
|
||
// Alias old functions so nothing breaks
|
||
function filterUsers(q) { filterGamers(q); }
|
||
function adjustTokens(uid) {}
|
||
function toggleUser(uid) {}
|
||
|
||
// ─── FULL HISTORY ──────────────────────────────────────────
|
||
var _histPage = 1;
|
||
|
||
async function loadHistory(page) {
|
||
if (page) _histPage = page;
|
||
const el = document.getElementById('history-table');
|
||
const pgEl = document.getElementById('hist-pagination');
|
||
const ctEl = document.getElementById('hist-count');
|
||
const stEl = document.getElementById('hist-stats');
|
||
|
||
el.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text2)">Loading audit log...</div>';
|
||
if (pgEl) pgEl.innerHTML = '';
|
||
|
||
const category = document.getElementById('hist-category')?.value || '';
|
||
const severity = document.getElementById('hist-severity')?.value || '';
|
||
const search = document.getElementById('hist-search')?.value.trim() || '';
|
||
const date = document.getElementById('hist-date')?.value || '';
|
||
|
||
const params = new URLSearchParams({
|
||
action: 'activity_log_v2',
|
||
page: _histPage,
|
||
limit: 20,
|
||
category, severity, search, date
|
||
});
|
||
|
||
const d = await fetch('/api/admin.php?' + params.toString()).then(r=>r.json());
|
||
if (!d.success) { el.innerHTML = '<div class="empty">Failed to load audit log.</div>'; return; }
|
||
|
||
const events = d.events || [];
|
||
const total = d.total || 0;
|
||
const pages = Math.ceil(total / 20);
|
||
|
||
if (ctEl) ctEl.textContent = total + ' total events';
|
||
|
||
// Stats bar
|
||
if (stEl && d.stats) {
|
||
const s = d.stats;
|
||
stEl.innerHTML = [
|
||
{ label:'Total Events', val: total, color:'var(--text)' },
|
||
{ label:'Critical', val: s.critical||0, color:'var(--red)' },
|
||
{ label:'Warnings', val: s.warning||0, color:'var(--gold)' },
|
||
{ label:'Unique IPs', val: s.unique_ips||0, color:'var(--cyan)' },
|
||
].map(x =>
|
||
'<div class="card" style="text-align:center;padding:10px 8px">'+
|
||
'<div style="font-family:\'Exo 2\',sans-serif;font-weight:900;font-size:20px;color:'+x.color+'">'+x.val+'</div>'+
|
||
'<div style="font-size:14px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px">'+x.label+'</div></div>'
|
||
).join('');
|
||
}
|
||
|
||
if (!events.length) {
|
||
el.innerHTML = '<div class="empty" style="padding:24px;text-align:center">No events match these filters.</div>';
|
||
return;
|
||
}
|
||
|
||
const SEV_COLORS = { critical:'var(--red)', warning:'var(--gold)', info:'var(--text2)' };
|
||
const CAT_ICONS = { auth:'🔐', player:'👤', admin:'🛡️', security:'⚠️', general:'📋' };
|
||
|
||
let lastDate = '';
|
||
let rows = '';
|
||
for (const e of events) {
|
||
const dt = (e.created_at||'').substring(0,10);
|
||
const time = (e.created_at||'').substring(11,19);
|
||
if (dt !== lastDate) {
|
||
lastDate = dt;
|
||
const isToday = dt === new Date().toISOString().substring(0,10);
|
||
const label = isToday ? 'Today' : new Date(dt+'T12:00:00').toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric',year:'numeric'});
|
||
rows += '<div style="padding:10px 0 6px;font-size:15px;font-weight:700;color:var(--text2);letter-spacing:1px;text-transform:uppercase;border-bottom:1px solid var(--border);margin-bottom:6px">'+label+'</div>';
|
||
}
|
||
|
||
const sevColor = SEV_COLORS[e.severity] || 'var(--text2)';
|
||
const catIcon = CAT_ICONS[e.category] || '📋';
|
||
const who = e.alias || e.username || '—';
|
||
const adminBy = e.admin_username ? ' <span style="font-size:14px;color:var(--gold)">by '+escHtmlA(e.admin_username)+'</span>' : '';
|
||
const action = e.action.replace(/_/g,' ');
|
||
|
||
// Build detail row
|
||
let details = '';
|
||
if (e.detail) details += '<div style="font-size:15px;color:var(--text2);margin-top:2px">'+escHtmlA(e.detail)+'</div>';
|
||
if (e.old_value && e.new_value)
|
||
details += '<div style="font-size:15px;margin-top:2px"><span style="color:var(--red)">'+escHtmlA(e.old_value)+'</span> → <span style="color:var(--green)">'+escHtmlA(e.new_value)+'</span></div>';
|
||
if (e.ip) details += '<div style="font-size:14px;color:var(--text2);margin-top:2px">IP: '+escHtmlA(e.ip)+' | Session: '+escHtmlA(e.session_id||'—')+'</div>';
|
||
if (e.page) details += '<div style="font-size:14px;color:var(--text2);">Page: '+escHtmlA(e.page)+'</div>';
|
||
if (e.user_agent)details += '<div style="font-size:15px;color:var(--text2);word-break:break-all;opacity:.7">UA: '+escHtmlA(e.user_agent.substring(0,120))+'</div>';
|
||
|
||
rows += '<div style="border-left:3px solid '+sevColor+';padding:8px 12px;margin-bottom:4px;border-radius:0 6px 6px 0;background:rgba(255,255,255,.02)">' +
|
||
'<div style="display:flex;align-items:flex-start;gap:10px">' +
|
||
'<span style="font-size:14px;flex-shrink:0;margin-top:1px">'+catIcon+'</span>' +
|
||
'<div style="flex:1;min-width:0">' +
|
||
'<span style="font-weight:700;font-size:15px;color:'+sevColor+'">'+action+'</span>'+adminBy +
|
||
details + '</div>' +
|
||
'<div style="text-align:right;flex-shrink:0;min-width:90px">' +
|
||
'<div style="font-size:14px;font-weight:700">'+escHtmlA(who)+'</div>' +
|
||
'<div style="font-size:14px;color:var(--text2)">'+time+'</div>' +
|
||
'<div style="font-size:15px;color:'+sevColor+';text-transform:uppercase;letter-spacing:.5px">'+e.severity+'</div>' +
|
||
'</div></div></div>';
|
||
}
|
||
|
||
el.innerHTML = '<div style="padding:4px 0">'+rows+'</div>';
|
||
|
||
// Pagination
|
||
if (pgEl && pages > 1) {
|
||
let pg = '';
|
||
if (_histPage > 1) pg += '<button onclick="loadHistory('+(_histPage-1)+')" class="btn btn-outline" style="padding:6px 14px;font-size:14px">← Prev</button>';
|
||
const start2 = Math.max(1, _histPage-2);
|
||
const end2 = Math.min(pages, _histPage+2);
|
||
for (let p = start2; p <= end2; p++) {
|
||
pg += '<button onclick="loadHistory('+p+')" class="btn '+(p===_histPage?'btn-gold':'btn-outline')+'" style="padding:6px 12px;font-size:14px">'+p+'</button>';
|
||
}
|
||
if (_histPage < pages) pg += '<button onclick="loadHistory('+(_histPage+1)+')" class="btn btn-outline" style="padding:6px 14px;font-size:14px">Next →</button>';
|
||
pg += '<span style="font-size:14px;color:var(--text2);align-self:center">Page '+_histPage+' of '+pages+'</span>';
|
||
pgEl.innerHTML = pg;
|
||
}
|
||
}
|
||
|
||
async function exportHistory() {
|
||
const category = document.getElementById('hist-category')?.value || '';
|
||
const severity = document.getElementById('hist-severity')?.value || '';
|
||
const search = document.getElementById('hist-search')?.value.trim() || '';
|
||
const date = document.getElementById('hist-date')?.value || '';
|
||
window.open('/api/admin.php?action=activity_log_csv&category='+encodeURIComponent(category)+'&severity='+encodeURIComponent(severity)+'&search='+encodeURIComponent(search)+'&date='+encodeURIComponent(date));
|
||
}
|
||
|
||
// ─── TOAST ─────────────────────────────────────────────────
|
||
function toast(msg,type='ok'){const el=document.getElementById('toast');el.textContent=msg;el.className='show '+type;setTimeout(()=>el.className='',3000);}
|
||
|
||
// ─── ADMIN GAMER ALIASES ──────────────────────────────────
|
||
async function gmLoadPayout(uid) {
|
||
const el = document.getElementById('gm-payout-list');
|
||
const d = await apiFetch('payout_methods_get&user_id=' + uid);
|
||
if (!d.success || !d.methods.length) {
|
||
el.innerHTML = '<div class="gm-empty">No payout methods saved by this player.</div>';
|
||
return;
|
||
}
|
||
const ICONS = { venmo:'💙', cashapp:'💚', zelle:'💜', chime:'🟢', bank:'🏦', other:'💰' };
|
||
const LABELS = { venmo:'Venmo', cashapp:'Cash App', zelle:'Zelle', chime:'Chime', bank:'Bank Transfer', other:'Other' };
|
||
el.innerHTML = d.methods.map(m => `
|
||
<div style="display:flex;align-items:center;gap:12px;padding:12px;background:${parseInt(m.is_default)?'rgba(0,229,255,.07)':'var(--bg2)'};border:1px solid ${parseInt(m.is_default)?'rgba(0,229,255,.25)':'var(--border)'};border-radius:8px;margin-bottom:8px">
|
||
<span style="font-size:24px">${ICONS[m.method_type]||'💰'}</span>
|
||
<div style="flex:1">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px">${escHtmlA(m.label)}
|
||
${parseInt(m.is_default)?'<span style="font-size:14px;color:var(--cyan);border:1px solid rgba(0,229,255,.2);border-radius:4px;padding:2px 6px;margin-left:6px">DEFAULT</span>':''}
|
||
</div>
|
||
<div style="font-size:14px;color:var(--text2)">${LABELS[m.method_type]||m.method_type} · ${escHtmlA(m.account_handle)}</div>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function gmLoadPlatformAccounts(uid) {
|
||
const el = document.getElementById('gm-platforms-list');
|
||
const d = await apiFetch('platform_accounts_user&user_id=' + uid);
|
||
if (!d.success || !d.accounts.length) {
|
||
el.innerHTML = '<div class="gm-empty">No platform account requests for this player.</div>';
|
||
return;
|
||
}
|
||
const STATUS = { pending:'⏳ Pending', approved:'✅ Active', denied:'❌ Denied', deleted:'🗑 Deleted' };
|
||
const SCLS = { pending:'ac-pending', approved:'ac-completed', denied:'ac-failed', deleted:'ac-failed' };
|
||
el.innerHTML = d.accounts.map(a => `
|
||
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:8px">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:${a.status==='approved'?'10':'0'}px">
|
||
<div style="font-size:20px">${a.color?`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${a.color}"></span>`:'🎮'}</div>
|
||
<div style="flex:1">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px">${escHtmlA(a.display_name||a.platform_slug)}</div>
|
||
<span class="activity-status ${SCLS[a.status]||'ac-pending'}">${STATUS[a.status]||a.status}</span>
|
||
</div>
|
||
${a.status==='approved'?`<button onclick="openPaUpdateModal(${a.id},'${escAttr(GM.current?.alias||'')}','${escAttr(a.display_name||'')}','${escAttr(a.provided_username||'')}','${escAttr(a.provided_password||'')}')" style="background:rgba(0,229,255,.08);border:1px solid rgba(0,229,255,.18);color:var(--cyan);border-radius:6px;padding:5px 10px;font-size:15px;font-weight:700;cursor:pointer">✏️ Update</button>`:
|
||
a.status==='pending'?`<button onclick="openPaModal(${a.id},'${escAttr(GM.current?.alias||'')}','${escAttr(a.display_name||'')}')" class="btn btn-green" style="padding:5px 12px;font-size:15px">✅ Approve</button>`:''}
|
||
</div>
|
||
${a.status==='approved'?`<div style="background:rgba(0,229,255,.05);border:1px solid rgba(0,229,255,.12);border-radius:6px;padding:8px 10px;font-size:14px">
|
||
<div><span style="color:var(--text2)">Username:</span> <strong>${escHtmlA(a.provided_username||'—')}</strong></div>
|
||
<div><span style="color:var(--text2)">Password:</span> <strong style="color:var(--gold)">${escHtmlA(a.provided_password||'—')}</strong></div>
|
||
${a.player_url?`<a href="${escHtmlA(a.player_url)}" target="_blank" style="color:var(--cyan);font-size:15px">↗ Open platform</a>`:''}
|
||
</div>`:''}
|
||
</div>`).join('');
|
||
}
|
||
async function gmLoadAliases(uid) {
|
||
const el = document.getElementById('gm-aliases-list');
|
||
const [aliasRes, platRes] = await Promise.all([
|
||
apiFetch('game_aliases_get&user_id=' + uid),
|
||
fetch('/api/platforms.php?action=list').then(r=>r.json())
|
||
]);
|
||
const aliases = (aliasRes.success ? aliasRes.aliases : {}) || {};
|
||
const platforms = platRes.success ? platRes.platforms : [];
|
||
if (!platforms.length) { el.innerHTML = '<div class="gm-empty">No games configured.</div>'; return; }
|
||
el.innerHTML = platforms.map(p => `
|
||
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
|
||
<div style="width:10px;height:10px;border-radius:50%;background:${p.color};box-shadow:0 0 8px ${p.color};flex-shrink:0"></div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text);margin-bottom:5px">${escHtmlA(p.name)}</div>
|
||
<input class="fi-sm" id="gm-alias-${p.id}" type="text" maxlength="100"
|
||
value="${escHtmlA(aliases[p.id]||'')}"
|
||
placeholder="No alias set..."
|
||
style="width:100%;padding:8px 12px;font-size:15px">
|
||
</div>
|
||
</div>`).join('') + '<div style="height:1px"></div>';
|
||
}
|
||
|
||
async function gmSaveAllAliases() {
|
||
if (!GM.current) return;
|
||
const al = document.getElementById('gm-aliases-alert');
|
||
const aliases = {};
|
||
document.querySelectorAll('[id^="gm-alias-"]').forEach(el => {
|
||
const slug = el.id.replace('gm-alias-', '');
|
||
aliases[slug] = el.value.trim();
|
||
});
|
||
const d = await apiFetch('game_aliases_save_all', 'POST', { user_id: GM.current.id, aliases });
|
||
if (d.success) { showAdminAlert(al, 'Aliases saved!', 'success'); toast('Aliases saved', 'ok'); }
|
||
else showAdminAlert(al, d.error || 'Save failed', 'error');
|
||
}
|
||
async function loadGamerBilling(uid) {
|
||
const d = await apiFetch('billing_get&user_id=' + uid);
|
||
const noCard = document.getElementById('gm-billing-no-card');
|
||
const cardDiv = document.getElementById('gm-billing-card-display');
|
||
const cardEl = document.getElementById('gm-billing-card');
|
||
|
||
if (d.success && d.billing) {
|
||
const b = d.billing;
|
||
// Fill form
|
||
['first','last','email','address','city','state','zip'].forEach(f => {
|
||
const el = document.getElementById('gm-b-'+f);
|
||
if (el) el.value = b[f==='first'?'first_name':f==='last'?'last_name':f] || '';
|
||
});
|
||
// Show card info
|
||
if (b.card_last4) {
|
||
cardEl.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<span style="font-family:'Exo 2',sans-serif;font-weight:900;font-size:16px;color:#fff;letter-spacing:2px">${escHtmlA(b.card_brand||'CARD')}</span>
|
||
<span style="font-size:20px">💳</span>
|
||
</div>
|
||
<div style="font-family:'Courier New',monospace;font-size:16px;color:#fff;letter-spacing:4px;margin-bottom:16px">···· ···· ···· ${escHtmlA(b.card_last4)}</div>
|
||
<div style="display:flex;justify-content:space-between;color:#fff">
|
||
<div><div style="font-size:15px;color:rgba(255,255,255,.5);margin-bottom:2px">CARD HOLDER</div><div style="font-weight:700">${escHtmlA([b.first_name,b.last_name].filter(Boolean).join(' '))||'—'}</div></div>
|
||
<div style="text-align:right"><div style="font-size:15px;color:rgba(255,255,255,.5);margin-bottom:2px">EXPIRES</div><div style="font-weight:700">${b.card_exp_month&&b.card_exp_year?b.card_exp_month+'/'+b.card_exp_year.slice(-2):'—'}</div></div>
|
||
</div>`;
|
||
cardDiv.style.display = 'block';
|
||
noCard.style.display = 'none';
|
||
} else {
|
||
cardDiv.style.display = 'none';
|
||
noCard.style.display = 'block';
|
||
}
|
||
} else {
|
||
cardDiv.style.display = 'none';
|
||
noCard.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function gmSaveBilling() {
|
||
if (!GM.current) return;
|
||
const al = document.getElementById('gm-billing-alert');
|
||
const data = {
|
||
user_id: GM.current.id,
|
||
first_name: document.getElementById('gm-b-first')?.value.trim() || '',
|
||
last_name: document.getElementById('gm-b-last')?.value.trim() || '',
|
||
email: document.getElementById('gm-b-email')?.value.trim() || '',
|
||
address: document.getElementById('gm-b-address')?.value.trim() || '',
|
||
city: document.getElementById('gm-b-city')?.value.trim() || '',
|
||
state: (document.getElementById('gm-b-state')?.value.trim() || '').toUpperCase(),
|
||
zip: document.getElementById('gm-b-zip')?.value.trim() || '',
|
||
};
|
||
const d = await apiFetch('billing_save','POST', data);
|
||
if (d.success) { toast('Billing info saved','ok'); showAdminAlert(al,'Billing saved!','success'); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function gmClearCard() {
|
||
if (!GM.current || !confirm('Remove saved card info for this player?')) return;
|
||
const d = await apiFetch('billing_clear_card','POST',{user_id:GM.current.id});
|
||
if (d.success) { toast('Card removed','ok'); loadGamerBilling(GM.current.id); }
|
||
else toast('Error','err');
|
||
}
|
||
|
||
async function gmClearAllBilling() {
|
||
if (!GM.current || !confirm('Clear ALL billing info for this player? Cannot be undone.')) return;
|
||
const d = await apiFetch('billing_clear_all','POST',{user_id:GM.current.id});
|
||
if (d.success) { toast('Billing cleared','ok'); loadGamerBilling(GM.current.id); }
|
||
else toast('Error','err');
|
||
}
|
||
|
||
// ─── PAYMENT SETTINGS ─────────────────────────────────────
|
||
const PMT_ICONS = { venmo:'💙', chime:'🟢', cashapp:'💚', zelle:'💜' };
|
||
|
||
async function loadPaymentSettings() {
|
||
const d = await apiFetch('payment_settings_list');
|
||
const el = document.getElementById('payment-methods-list');
|
||
if (!d.success) { el.innerHTML = '<div class="empty">Failed to load.</div>'; return; }
|
||
// Sort: card first, then by sort_order
|
||
const methods = [...d.methods].sort((a,b) => a.method_key==='card'?-1:b.method_key==='card'?1:a.sort_order-b.sort_order);
|
||
el.innerHTML = methods.map(m => {
|
||
const isCard = m.method_key === 'card';
|
||
const enabled = parseInt(m.is_enabled);
|
||
const icon = PMT_ICONS[m.method_key] || '💰';
|
||
const bg = enabled ? 'var(--green)' : 'rgba(255,255,255,.15)';
|
||
const knobL = enabled ? '22px' : '2px';
|
||
const clr = enabled ? 'var(--green)' : 'var(--red)';
|
||
|
||
const fields = isCard
|
||
? '<div style="padding:10px 14px;background:rgba(0,229,255,.05);border-radius:8px;font-size:14px;color:var(--text2)">'+
|
||
'Card payments run through <strong style="color:var(--cyan)">Square</strong>. When enabled, players can pay by credit or debit card. Your Square account must be active and connected.'+
|
||
'<a href="https://squareup.com/dashboard" target="_blank" style="color:var(--cyan);display:block;margin-top:6px">Open Square Dashboard →</a></div>'
|
||
: '<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">'+
|
||
'<div><label class="gm-edit-label">Display Name</label>'+
|
||
'<input class="fi-sm" id="pmt-label-'+m.id+'" type="text" value="'+escHtmlA(m.label)+'" style="width:100%;padding:9px 12px"></div>'+
|
||
'<div><label class="gm-edit-label">Handle / Address</label>'+
|
||
'<input class="fi-sm" id="pmt-handle-'+m.id+'" type="text" value="'+escHtmlA(m.handle||'')+'" placeholder="@YourHandle" style="width:100%;padding:9px 12px"></div></div>'+
|
||
'<div style="margin-bottom:10px"><label class="gm-edit-label">Instructions (optional)</label>'+
|
||
'<input class="fi-sm" id="pmt-instructions-'+m.id+'" type="text" value="'+escHtmlA(m.instructions||'')+'" placeholder="Shown to player at checkout" style="width:100%;padding:9px 12px"></div>'+
|
||
'<button class="btn btn-gold" style="width:100%" onclick="savePaymentMethod('+m.id+')">Save</button>';
|
||
|
||
return '<div class="card" style="margin-bottom:12px;'+(isCard?'border-color:rgba(0,229,255,.25)':'')+'" id="pmt-card-'+m.id+'">'+
|
||
'<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">'+
|
||
'<div style="font-size:26px">'+icon+'</div>'+
|
||
'<div style="flex:1"><div style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:15px">'+escHtmlA(m.label)+'</div>'+
|
||
'<div style="font-size:15px;color:var(--text2);margin-top:1px">'+(isCard?'Square Card Processing':'Manual payment'+(m.handle?' · '+escHtmlA(m.handle):''))+'</div></div>'+
|
||
'<label style="display:flex;align-items:center;gap:8px;cursor:pointer">'+
|
||
'<span style="font-size:14px;color:'+clr+'" id="pmt-status-'+m.id+'">'+(enabled?'Enabled':'Disabled')+'</span>'+
|
||
'<div onclick="togglePaymentMethod('+m.id+','+m.is_enabled+')" style="width:44px;height:24px;border-radius:12px;background:'+bg+';position:relative;cursor:pointer;transition:background .2s;flex-shrink:0" id="pmt-toggle-'+m.id+'">'+
|
||
'<div style="position:absolute;top:2px;left:'+knobL+';width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s" id="pmt-knob-'+m.id+'"></div></div>'+
|
||
'</label></div>'+
|
||
fields+'</div>';
|
||
}).join('');
|
||
window._pmtData = d.methods;
|
||
}
|
||
|
||
async function togglePaymentMethod(id, currentEnabled) {
|
||
const newEnabled = parseInt(currentEnabled) ? 0 : 1;
|
||
const d = await apiFetch('payment_settings_update','POST',{
|
||
id, is_enabled: newEnabled,
|
||
label: document.getElementById('pmt-label-'+id)?.value || '',
|
||
handle: document.getElementById('pmt-handle-'+id)?.value || '',
|
||
instructions: document.getElementById('pmt-instructions-'+id)?.value || '',
|
||
sort_order: 0
|
||
});
|
||
if (d.success) {
|
||
// Update toggle UI
|
||
const toggle = document.getElementById('pmt-toggle-'+id);
|
||
const knob = document.getElementById('pmt-knob-'+id);
|
||
const status = document.getElementById('pmt-status-'+id);
|
||
if (toggle) toggle.style.background = newEnabled ? 'var(--green)' : 'rgba(255,255,255,.15)';
|
||
if (knob) knob.style.left = newEnabled ? '22px' : '2px';
|
||
if (status) { status.textContent = newEnabled ? 'Enabled' : 'Disabled'; status.style.color = newEnabled ? 'var(--green)' : 'var(--red)'; }
|
||
// Update onclick for next toggle
|
||
const toggleEl = document.getElementById('pmt-toggle-'+id)?.parentElement;
|
||
if (toggleEl) toggleEl.setAttribute('onclick', `togglePaymentMethod(${id},${newEnabled})`);
|
||
toast(newEnabled ? 'Payment method enabled' : 'Payment method disabled', 'ok');
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function savePaymentMethod(id) {
|
||
const d = await apiFetch('payment_settings_update','POST',{
|
||
id,
|
||
label: document.getElementById('pmt-label-'+id)?.value.trim() || '',
|
||
handle: document.getElementById('pmt-handle-'+id)?.value.trim() || '',
|
||
instructions: document.getElementById('pmt-instructions-'+id)?.value.trim() || '',
|
||
is_enabled: window._pmtData?.find(m=>m.id==id)?.is_enabled ?? 1,
|
||
sort_order: window._pmtData?.find(m=>m.id==id)?.sort_order ?? 0,
|
||
});
|
||
if (d.success) toast('Saved!','ok');
|
||
else toast(d.error||'Save failed','err');
|
||
}
|
||
|
||
|
||
// PAYOUT SETTINGS
|
||
async function loadPayoutSettings() {
|
||
const el = document.getElementById('payout-settings-list');
|
||
if (!el) return;
|
||
const d = await fetch('/api/payout_process.php?action=list_settings').then(r=>r.json());
|
||
if (!d.success || !d.settings.length) { el.innerHTML='<div class="empty">No payout settings.</div>'; return; }
|
||
window._payoutSettings = d.settings;
|
||
const icons = {venmo:'Venmo',cashapp:'Cash App',zelle:'Zelle',chime:'Chime',square_gift:'Square Gift Card'};
|
||
el.innerHTML = d.settings.map(s => {
|
||
const isGC = s.method_type === 'square_gift_card';
|
||
const enabled = parseInt(s.is_enabled);
|
||
return '<div class="card" style="margin-bottom:12px;' + (isGC?'border-color:rgba(0,229,255,.3)':'') + '">' +
|
||
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">' +
|
||
'<div style="flex:1"><div style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:14px">' + escHtmlA(s.label) + '</div>' +
|
||
'<div style="font-size:15px;color:' + (isGC?'var(--cyan)':'var(--text2)') + '">' + (isGC?'Real-time via Square':'Manual — send outside app') + '</div></div>' +
|
||
'<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:14px;font-weight:700">' +
|
||
'<input type="checkbox" ' + (enabled?'checked':'') + ' onchange="togglePayoutSetting(' + s.id + ',this.checked)" style="width:16px;height:16px;accent-color:var(--green)"> ' +
|
||
(enabled?'<span style="color:var(--green)">Enabled</span>':'<span style="color:var(--text2)">Disabled</span>') + '</label></div>' +
|
||
(isGC ? '<div style="font-size:14px;color:var(--text2);padding:10px;background:rgba(0,229,255,.05);border-radius:8px">Creates a Square digital gift card loaded with the cashout amount. Player redeems anywhere Square is accepted. No handle needed.</div>' :
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">' +
|
||
'<div><label class="gm-edit-label">Your Handle</label><input class="fi-sm" type="text" id="ps-handle-' + s.id + '" value="' + escHtmlA(s.handle||'') + '" placeholder="@handle" style="width:100%;padding:8px 10px"></div>' +
|
||
'<div><label class="gm-edit-label">Sort</label><input class="fi-sm" type="number" id="ps-sort-' + s.id + '" value="' + s.sort_order + '" style="width:100%;padding:8px 10px"></div></div>' +
|
||
'<div style="margin-bottom:10px"><label class="gm-edit-label">Instructions</label><input class="fi-sm" type="text" id="ps-instr-' + s.id + '" value="' + escHtmlA(s.instructions||'') + '" placeholder="Steps to send" style="width:100%;padding:8px 10px"></div>' +
|
||
'<button class="btn btn-gold" onclick="savePayoutSetting(' + s.id + ')" style="font-size:14px;padding:8px 16px">Save</button>') +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
async function togglePayoutSetting(id, enabled) {
|
||
await fetch('/api/payout_process.php?action=update_setting',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,is_enabled:enabled?1:0})}).then(r=>r.json());
|
||
}
|
||
async function savePayoutSetting(id) {
|
||
const d = await fetch('/api/payout_process.php?action=update_setting',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({id,handle:document.getElementById('ps-handle-'+id)?.value.trim(),instructions:document.getElementById('ps-instr-'+id)?.value.trim(),sort_order:parseInt(document.getElementById('ps-sort-'+id)?.value)||0,is_enabled:1})}).then(r=>r.json());
|
||
if (d.success) toast('Saved','ok'); else toast('Error','err');
|
||
}
|
||
|
||
// PROCESS PAYOUT MODAL
|
||
let PPM = {cashoutId:null,cashout:null,settings:[]};
|
||
async function openPayoutModal(cashoutId) {
|
||
PPM.cashoutId=cashoutId;
|
||
const modal=document.getElementById('process-payout-modal');
|
||
modal.style.display='flex';
|
||
document.getElementById('ppm-alert').className='alert';
|
||
document.getElementById('ppm-note').value='';
|
||
document.getElementById('ppm-method-detail').innerHTML='';
|
||
const [dr,sr]=await Promise.all([
|
||
fetch('/api/payout_process.php?action=cashout_detail&id='+cashoutId).then(r=>r.json()),
|
||
fetch('/api/payout_process.php?action=list_settings').then(r=>r.json()),
|
||
]);
|
||
if (!dr.success){toast('Load failed','err');closePayoutModal();return;}
|
||
PPM.cashout=dr.cashout;
|
||
PPM.settings=(sr.settings||[]).filter(s=>parseInt(s.is_enabled));
|
||
const c=PPM.cashout;
|
||
const amt=parseFloat(c.tokens).toFixed(2);
|
||
document.getElementById('ppm-player-info').innerHTML=
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">'+
|
||
'<div><div style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:16px">'+escHtmlA(c.alias||c.username)+'</div>'+
|
||
'<div style="font-size:14px;color:var(--text2)">@'+escHtmlA(c.username)+' | '+escHtmlA(c.email||'')+'</div>'+
|
||
'<div style="font-size:14px;color:var(--text2)">'+pname(c.platform_id)+' | '+escHtmlA(c.alias||'')+'</div></div>'+
|
||
'<div style="text-align:right"><div style="font-family:\'Exo 2\',sans-serif;font-weight:900;font-size:28px;color:var(--gold)">'+c.tokens+' Tokens</div>'+
|
||
'<div style="font-size:14px;font-weight:700;color:var(--green)">$'+amt+'</div></div></div>';
|
||
const spm=document.getElementById('ppm-saved-method');
|
||
const ph=c.saved_payout_handle||c.payout_handle;
|
||
const pt=c.saved_payout_label||c.payout_method_type||'';
|
||
spm.innerHTML=ph?
|
||
'<div style="background:rgba(0,229,255,.06);border:1px solid rgba(0,229,255,.15);border-radius:8px;padding:10px 14px">'+
|
||
'<div style="font-size:15px;font-weight:700;color:var(--text2);margin-bottom:4px">PLAYER PAYOUT METHOD</div>'+
|
||
'<div style="font-size:14px;font-weight:700">'+escHtmlA(pt)+' | <span style="color:var(--cyan)">'+escHtmlA(ph)+'</span></div></div>':
|
||
'<div style="font-size:14px;color:var(--red)">No payout method on file for this player</div>';
|
||
const sel=document.getElementById('ppm-method-select');
|
||
sel.innerHTML='<option value="">-- Select method --</option>'+
|
||
PPM.settings.map(s=>'<option value="'+escHtmlA(s.method_key)+'" data-type="'+s.method_type+'">'+
|
||
(s.method_type==='square_gift_card'?'[INSTANT] ':'')+escHtmlA(s.label)+'</option>').join('');
|
||
}
|
||
function closePayoutModal(){
|
||
document.getElementById('process-payout-modal').style.display='none';
|
||
PPM={cashoutId:null,cashout:null,settings:[]};
|
||
}
|
||
function onPayoutMethodChange(){
|
||
const key=document.getElementById('ppm-method-select').value;
|
||
const detail=document.getElementById('ppm-method-detail');
|
||
const btn=document.getElementById('ppm-submit-btn');
|
||
if (!key){detail.innerHTML='';btn.textContent='Process Payout';return;}
|
||
const s=PPM.settings.find(x=>x.method_key===key);
|
||
if (!s) return;
|
||
const amt=parseFloat(PPM.cashout?.tokens||0).toFixed(2);
|
||
const c=PPM.cashout;
|
||
if (s.method_type==='square_gift_card'){
|
||
detail.innerHTML='<div style="background:rgba(0,229,255,.07);border:1px solid rgba(0,229,255,.2);border-radius:10px;padding:14px">'+
|
||
'<div style="font-size:15px;font-weight:700;color:var(--cyan);margin-bottom:8px">INSTANT Square Gift Card</div>'+
|
||
'<div style="font-size:15px">Creates a digital gift card for <strong style="color:var(--gold)">$'+amt+'</strong> via your Square account instantly. Card number shows here — send to player via chat.</div></div>';
|
||
btn.textContent='Process via Square Gift Card';
|
||
} else {
|
||
const ph=c?.payout_handle||c?.saved_payout_handle||'(none on file)';
|
||
detail.innerHTML='<div style="background:rgba(240,192,64,.07);border:1px solid rgba(240,192,64,.2);border-radius:10px;padding:14px">'+
|
||
'<div style="font-size:15px;font-weight:700;color:var(--gold);margin-bottom:8px">Manual Processing</div>'+
|
||
'<div style="font-size:15px;line-height:2.0">'+
|
||
'1. Open <strong>'+escHtmlA(s.label)+'</strong><br>'+
|
||
'2. Send <strong style="color:var(--gold)">$'+amt+'</strong> to: <span style="color:var(--cyan)"><strong>'+escHtmlA(ph)+'</strong></span><br>'+
|
||
(s.instructions?'3. '+escHtmlA(s.instructions):'')+'</div>'+
|
||
(s.handle?'<div style="font-size:14px;color:var(--text2);margin-top:8px">Your handle: <strong>'+escHtmlA(s.handle)+'</strong></div>':'')+'</div>';
|
||
btn.textContent='Mark as Sent (Manual)';
|
||
}
|
||
}
|
||
async function submitPayout(){
|
||
const key=document.getElementById('ppm-method-select').value;
|
||
const note=document.getElementById('ppm-note').value.trim();
|
||
const al=document.getElementById('ppm-alert');
|
||
al.className='alert';
|
||
if (!key){showAdminAlert(al,'Select a payout method.','error');return;}
|
||
const s=PPM.settings.find(x=>x.method_key===key);
|
||
if (!s) return;
|
||
const isGC=s.method_type==='square_gift_card';
|
||
const amt=parseFloat(PPM.cashout?.tokens||0).toFixed(2);
|
||
if (!confirm(isGC?'Create Square gift card for $'+amt+'?':'Confirm you have sent $'+amt+' via '+s.label+'. Mark as sent?')) return;
|
||
const btn=document.getElementById('ppm-submit-btn');
|
||
btn.disabled=true; btn.textContent='Processing...';
|
||
try {
|
||
const res=await fetch('/api/payout_process.php?action=process_payout',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({cashout_id:PPM.cashoutId,payout_method_key:key,payout_type:s.method_type,note})}).then(r=>r.json());
|
||
if (res.success){
|
||
showAdminAlert(al,isGC?'Gift card created!':'Marked as sent!','success');
|
||
if (res.gift_card_gan){
|
||
al.innerHTML+='<div style="margin-top:10px;font-family:monospace;font-size:16px;color:var(--gold);word-break:break-all;user-select:all">Card #: <strong>'+res.gift_card_gan+'</strong></div>'+
|
||
'<div style="font-size:14px;color:var(--text2);margin-top:4px">Copy and send this card number to the player. Balance: $'+(res.gift_card_balance/100).toFixed(2)+'</div>';
|
||
}
|
||
toast(isGC?'Gift card sent!':'Marked sent','ok');
|
||
loadCashouts('pending');loadDashCashouts();loadStats();
|
||
if (!isGC) setTimeout(closePayoutModal,1500);
|
||
} else {
|
||
showAdminAlert(al,res.error||'Processing failed','error');
|
||
}
|
||
} catch(e){showAdminAlert(al,'Network error','error');}
|
||
finally{btn.disabled=false;btn.textContent=isGC?'Process via Square Gift Card':'Mark as Sent (Manual)';}
|
||
}
|
||
|
||
// ─── REFERRAL MANAGEMENT ──────────────────────────────────
|
||
let _refTiers = [];
|
||
|
||
function showRefSection(section) {
|
||
document.getElementById('ref-admin-list').style.display = section==='list'?'block':'none';
|
||
document.getElementById('ref-tiers-section').style.display = section==='tiers'?'block':'none';
|
||
document.getElementById('ref-shares-section').style.display = section==='shares'?'block':'none';
|
||
if (section==='tiers') loadAdminTiers();
|
||
if (section==='shares') loadAdminShares('pending', document.querySelector('#ref-shares-section .ftab'));
|
||
}
|
||
|
||
async function loadAdminReferrals(status, btn) {
|
||
document.getElementById('ref-admin-list').style.display='block';
|
||
document.getElementById('ref-tiers-section').style.display='none';
|
||
document.getElementById('ref-shares-section').style.display='none';
|
||
if (btn) { document.querySelectorAll('#section-referrals .ftab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); }
|
||
const el = document.getElementById('ref-admin-list');
|
||
el.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text2)">Loading...</div>';
|
||
const d = await fetch('/api/referrals.php?action=admin_list&status='+status).then(r=>r.json());
|
||
if (!d.success||!d.referrals.length) { el.innerHTML='<div class="empty" style="padding:24px;text-align:center">No '+status+' referrals.</div>'; return; }
|
||
el.innerHTML = d.referrals.map(r => buildRefCard(r, status)).join('');
|
||
}
|
||
|
||
function buildRefCard(r, status) {
|
||
const note = r.admin_note ? '<div style="font-size:15px;color:var(--text2)">'+escHtmlA(r.admin_note)+'</div>' : '';
|
||
const awarded = r.tokens_awarded>0 ? '<div style="font-size:15px;color:var(--green)">+'+r.tokens_awarded+' tokens awarded</div>' : '';
|
||
const actions = status==='pending' ? [
|
||
'<input class="fi-sm" type="text" id="rn-'+r.id+'" placeholder="Note" style="width:100%;padding:7px 10px;font-size:14px;margin-bottom:6px">',
|
||
'<button class="btn btn-green" style="width:100%;font-size:14px;padding:7px;margin-bottom:4px" onclick="resolveReferral('+r.id+', \'verified\')">Verify + Award</button>',
|
||
'<button class="btn btn-red" style="width:100%;font-size:14px;padding:7px" onclick="resolveReferral('+r.id+', \'denied\')">Deny</button>',
|
||
].join('') : '';
|
||
return '<div class="card" style="margin-bottom:10px"><div style="display:flex;align-items:flex-start;gap:12px">'+
|
||
'<div style="flex:1"><div style="font-size:15px"><strong>'+escHtmlA(r.referrer_alias||r.referrer_name)+'</strong> referred <strong>'+escHtmlA(r.referred_alias||r.referred_name)+'</strong></div>'+
|
||
'<div style="font-size:15px;color:var(--text2);margin-top:3px">'+(r.created_at||'').substring(0,16)+' | Email verified: '+(r.email_verified?'Yes':'No')+'</div>'+
|
||
awarded+note+'</div>'+
|
||
'<div style="flex-shrink:0;min-width:140px">'+actions+'</div></div></div>';
|
||
}
|
||
|
||
async function resolveReferral(id, status) {
|
||
const note = document.getElementById('rn-'+id)?.value.trim() || '';
|
||
if (!confirm(status==='verified' ? 'Verify and award tokens?' : 'Deny this referral?')) return;
|
||
const d = await fetch('/api/referrals.php?action=resolve_referral', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({id, status, note})
|
||
}).then(r=>r.json());
|
||
if (d.success) {
|
||
toast(status==='verified' ? 'Verified! '+d.tokens_awarded+' tokens awarded' : 'Denied', status==='verified'?'ok':'err');
|
||
loadAdminReferrals('pending', document.querySelector('#section-referrals .ftab'));
|
||
loadStats();
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function loadAdminTiers() {
|
||
const el = document.getElementById('ref-tiers-admin-list');
|
||
const d = await fetch('/api/referrals.php?action=all_tiers').then(r=>r.json());
|
||
if (!d.success||!d.tiers.length) { el.innerHTML='<div class="empty" style="padding:20px;text-align:center">No tiers yet. Add one above.</div>'; return; }
|
||
_refTiers = d.tiers;
|
||
el.innerHTML = d.tiers.map(t => {
|
||
const inactive = !parseInt(t.is_active) ? '<span style="font-size:14px;color:var(--red);margin-left:6px">INACTIVE</span>' : '';
|
||
return '<div style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border)">'+
|
||
'<div style="flex:1"><div style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:14px">'+escHtmlA(t.name)+inactive+'</div>'+
|
||
'<div style="font-size:14px;color:var(--text2)">Min '+t.min_referrals+' refs | '+t.tokens_per_ref+' tok/ref'+(t.bonus_tokens>0?' + '+t.bonus_tokens+' milestone bonus':'')+'</div></div>'+
|
||
'<div style="display:flex;gap:6px">'+
|
||
'<button data-tid="'+t.id+'" onclick="editRefTier(this.dataset.tid)" style="background:rgba(0,229,255,.08);border:1px solid rgba(0,229,255,.2);color:var(--cyan);border-radius:6px;padding:6px 12px;font-size:14px;font-weight:700;cursor:pointer">Edit</button>'+
|
||
'<button data-tid="'+t.id+'" data-tname="'+escHtmlA(t.name)+'" onclick="deleteRefTier(this.dataset.tid,this.dataset.tname)" style="background:rgba(255,68,68,.08);border:1px solid rgba(255,68,68,.2);color:var(--red);border-radius:6px;padding:6px 12px;font-size:14px;font-weight:700;cursor:pointer">Del</button>'+
|
||
'</div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
function editRefTier(id) {
|
||
const t = _refTiers.find(x=>x.id==id); if (!t) return;
|
||
document.getElementById('rt-id').value=t.id; document.getElementById('rt-name').value=t.name;
|
||
document.getElementById('rt-min').value=t.min_referrals; document.getElementById('rt-per').value=t.tokens_per_ref;
|
||
document.getElementById('rt-bonus').value=t.bonus_tokens; document.getElementById('rt-sort').value=t.sort_order;
|
||
document.getElementById('rt-desc').value=t.description||''; document.getElementById('rt-active').value=t.is_active;
|
||
document.getElementById('ref-tier-form-title').textContent = 'Editing: '+t.name;
|
||
}
|
||
|
||
function resetRefTierForm() {
|
||
['rt-id','rt-name','rt-desc'].forEach(id=>document.getElementById(id).value='');
|
||
document.getElementById('rt-min').value=1; document.getElementById('rt-per').value=5;
|
||
document.getElementById('rt-bonus').value=0; document.getElementById('rt-sort').value=0;
|
||
document.getElementById('rt-active').value=1;
|
||
document.getElementById('ref-tier-form-title').textContent='Add Referral Tier';
|
||
}
|
||
|
||
async function saveRefTier() {
|
||
const al = document.getElementById('ref-tier-alert');
|
||
const id = document.getElementById('rt-id').value;
|
||
const data = {
|
||
id: id ? parseInt(id) : undefined,
|
||
name: document.getElementById('rt-name').value.trim(),
|
||
min_referrals: parseInt(document.getElementById('rt-min').value)||1,
|
||
tokens_per_ref: parseFloat(document.getElementById('rt-per').value)||5,
|
||
bonus_tokens: parseFloat(document.getElementById('rt-bonus').value)||0,
|
||
description: document.getElementById('rt-desc').value.trim(),
|
||
sort_order: parseInt(document.getElementById('rt-sort').value)||0,
|
||
is_active: parseInt(document.getElementById('rt-active').value),
|
||
};
|
||
if (!data.name) { showAdminAlert(al,'Name is required.','error'); return; }
|
||
const d = await fetch('/api/referrals.php?action='+(id?'tier_update':'tier_create'),{
|
||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)
|
||
}).then(r=>r.json());
|
||
if (d.success) { showAdminAlert(al,id?'Updated!':'Created!','success'); resetRefTierForm(); loadAdminTiers(); toast('Saved','ok'); }
|
||
else showAdminAlert(al,d.error||'Error','error');
|
||
}
|
||
|
||
async function deleteRefTier(id, name) {
|
||
if (!confirm('Delete tier "'+name+'"?')) return;
|
||
const d = await fetch('/api/referrals.php?action=tier_delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}).then(r=>r.json());
|
||
if (d.success) { toast('Deleted','ok'); loadAdminTiers(); } else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function loadAdminShares(status, btn) {
|
||
if (btn) { document.querySelectorAll('#ref-shares-section .ftab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); }
|
||
const el = document.getElementById('ref-shares-admin-list');
|
||
const d = await fetch('/api/referrals.php?action=admin_shares&status='+status).then(r=>r.json());
|
||
if (!d.success||!d.shares.length) { el.innerHTML='<div class="empty" style="padding:20px;text-align:center">No '+status+' shares.</div>'; return; }
|
||
el.innerHTML = d.shares.map(s => {
|
||
const btns = status==='pending' ?
|
||
'<div style="display:flex;gap:6px">'+
|
||
'<button data-sid="'+s.id+'" onclick="resolveShare(this.dataset.sid,"approved")" class="btn btn-green" style="font-size:14px;padding:7px 12px">Approve</button>'+
|
||
'<button data-sid="'+s.id+'" onclick="resolveShare(this.dataset.sid,"denied")" class="btn btn-red" style="font-size:14px;padding:7px 12px">Deny</button></div>' : '';
|
||
return '<div class="card" style="margin-bottom:8px;display:flex;align-items:center;gap:12px">'+
|
||
'<div style="flex:1"><div style="font-size:15px;font-weight:700">'+escHtmlA(s.alias||s.username)+' | <span style="color:var(--cyan)">'+escHtmlA(s.platform)+'</span></div>'+
|
||
'<div style="font-size:15px;color:var(--text2)">'+(s.created_at||'').substring(0,16)+' | Bonus: '+s.bonus_tokens+' tokens</div></div>'+btns+'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function resolveShare(id, status) {
|
||
const d = await fetch('/api/referrals.php?action=resolve_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,status})}).then(r=>r.json());
|
||
if (d.success) { toast(status==='approved'?'Approved! Tokens awarded':'Denied','ok'); loadAdminShares('pending'); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// ─── PLATFORM ACCOUNTS ────────────────────────────────────
|
||
async function loadPlatformAccountRequests(status, btn) {
|
||
if (btn) { document.querySelectorAll('#section-platform-accounts .ftab').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); }
|
||
const el = document.getElementById('pa-requests-list');
|
||
el.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text2)">Loading...</div>';
|
||
const d = await apiFetch('platform_accounts_list&status='+status);
|
||
if (!d.success||!d.accounts.length) { el.innerHTML='<div class="empty" style="padding:24px;text-align:center">No '+status+' requests.</div>'; return; }
|
||
el.innerHTML = d.accounts.map(a => buildAdminPaCard(a, status==='pending')).join('');
|
||
}
|
||
|
||
async function gmLoadPlatformAccounts(uid) {
|
||
const el = document.getElementById('gm-platform-accounts-list');
|
||
const d = await apiFetch('platform_accounts_list&user_id='+uid);
|
||
if (!d.success||!d.accounts.length) { el.innerHTML='<div class="gm-empty">No platform account requests.</div>'; return; }
|
||
el.innerHTML = d.accounts.map(a => buildAdminPaCard(a, true)).join('');
|
||
}
|
||
|
||
function buildAdminPaCard(a, showActions) {
|
||
const statusLabel = { pending:'⏳ Pending', approved:'✅ Approved', denied:'❌ Denied', deleted:'🗑 Deleted' };
|
||
const statusColor = { pending:'var(--gold)', approved:'var(--green)', denied:'var(--red)', deleted:'var(--text2)' };
|
||
return '<div class="card" style="margin-bottom:12px">' +
|
||
'<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px">' +
|
||
'<div><div style="font-family:\'Exo 2\',sans-serif;font-weight:700;font-size:14px">' + escHtmlA(a.username||'?') + ' <span style="color:var(--text2)">· ' + escHtmlA(a.user_alias||'') + '</span></div>' +
|
||
'<div style="font-size:14px;color:var(--cyan)">' + escHtmlA(a.platform_name||a.platform_slug) + '</div>' +
|
||
'<div style="font-size:15px;color:var(--text2)">' + (a.requested_at||'').substring(0,16) + '</div></div>' +
|
||
'<span style="font-size:15px;font-weight:700;color:' + (statusColor[a.status]||'var(--text2)') + ';border:1px solid currentColor;border-radius:6px;padding:3px 10px">' + (statusLabel[a.status]||a.status) + '</span></div>' +
|
||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">' +
|
||
'<div><label class="gm-edit-label">Username</label><input class="fi-sm" type="text" id="pa-uname-' + a.id + '" value="' + escHtmlA(a.platform_username||'') + '" placeholder="username" style="width:100%;padding:8px 10px"></div>' +
|
||
'<div><label class="gm-edit-label">Password</label><input class="fi-sm" type="text" id="pa-pass-' + a.id + '" value="' + escHtmlA(a.platform_password||'') + '" placeholder="password" style="width:100%;padding:8px 10px"></div></div>' +
|
||
'<div style="margin-bottom:10px"><label class="gm-edit-label">Note to Player</label>' +
|
||
'<input class="fi-sm" type="text" id="pa-note-' + a.id + '" value="' + escHtmlA(a.admin_note||'') + '" placeholder="Optional note" style="width:100%;padding:8px 10px"></div>' +
|
||
'<div style="display:flex;gap:8px">' +
|
||
(a.status==='pending' ? '<button class="btn btn-green" onclick="resolvePlatformAccount('+a.id+',\'approved\')" style="flex:1;font-size:14px">Approve</button><button class="btn btn-red" onclick="resolvePlatformAccount('+a.id+',\'denied\')" style="font-size:14px;padding:8px 14px">Deny</button>' : '') +
|
||
(a.status==='approved' ? '<button class="btn btn-gold" onclick="updatePlatformAccount('+a.id+')" style="flex:1;font-size:14px">Update Credentials</button>' : '') +
|
||
'</div></div>';
|
||
}
|
||
|
||
async function resolvePlatformAccount(id, status) {
|
||
const uname = document.getElementById('pa-uname-'+id)?.value.trim();
|
||
const pass = document.getElementById('pa-pass-'+id)?.value.trim();
|
||
const note = document.getElementById('pa-note-'+id)?.value.trim();
|
||
if (status==='approved'&&!uname) { toast('Username required to approve','err'); return; }
|
||
if (!confirm(status==='approved'?'Approve and send credentials to player?':'Deny this request?')) return;
|
||
const d = await apiFetch('platform_account_resolve','POST',{id,status,platform_username:uname,platform_password:pass,admin_note:note});
|
||
if (d.success) {
|
||
toast(status==='approved'?'Approved!':'Denied','ok');
|
||
loadPlatformAccountRequests('pending', document.querySelector('#section-platform-accounts .ftab'));
|
||
if (GM.current) gmLoadPlatformAccounts(GM.current.id);
|
||
} else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function updatePlatformAccount(id) {
|
||
const d = await apiFetch('platform_account_update','POST',{
|
||
id, platform_username: document.getElementById('pa-uname-'+id)?.value.trim(),
|
||
platform_password: document.getElementById('pa-pass-'+id)?.value.trim(),
|
||
admin_note: document.getElementById('pa-note-'+id)?.value.trim()
|
||
});
|
||
if (d.success) toast('Updated','ok'); else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function sendBroadcast() {
|
||
const subject = document.getElementById('bc-subject')?.value.trim();
|
||
const message = document.getElementById('bc-message')?.value.trim();
|
||
const target = document.getElementById('bc-target')?.value || 'all';
|
||
const al = document.getElementById('bc-alert');
|
||
if (al) al.className = 'alert';
|
||
|
||
if (!subject) { if(al) showAdminAlert(al,'Subject is required.','error'); return; }
|
||
if (!message) { if(al) showAdminAlert(al,'Message is required.','error'); return; }
|
||
|
||
const btn = document.querySelector('#section-broadcasts .btn-gold');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
|
||
|
||
const d = await apiFetch('broadcast_send','POST',{ subject, message, target });
|
||
|
||
if (btn) { btn.disabled = false; btn.textContent = '📢 SEND BROADCAST'; }
|
||
|
||
if (d.success) {
|
||
if (al) showAdminAlert(al, '✓ Broadcast sent to ' + (d.recipient_count||0) + ' players!', 'success');
|
||
document.getElementById('bc-subject').value = '';
|
||
document.getElementById('bc-message').value = '';
|
||
setTimeout(() => { if(al){al.className='alert';al.textContent='';} }, 4000);
|
||
loadBroadcasts();
|
||
} else {
|
||
if (al) showAdminAlert(al, d.error || 'Failed to send.', 'error');
|
||
}
|
||
}
|
||
|
||
async function loadBroadcasts() {
|
||
const el = document.getElementById('bc-list');
|
||
if (!el) return;
|
||
el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text2)">Loading broadcasts...</div>';
|
||
let d;
|
||
try { d = await apiFetch('broadcast_list'); } catch(e) {
|
||
el.innerHTML = '<div class="empty" style="padding:24px;text-align:center;color:var(--red)">Failed to load broadcasts.</div>';
|
||
return;
|
||
}
|
||
if (!d || !d.success) {
|
||
el.innerHTML = '<div class="empty" style="padding:24px;text-align:center;color:var(--red)">' + (d?.error||'Error loading broadcasts') + '</div>';
|
||
return;
|
||
}
|
||
if (!d.broadcasts || !d.broadcasts.length) {
|
||
el.innerHTML = '<div class="empty" style="padding:24px;text-align:center;color:var(--text2)">No broadcasts sent yet.</div>';
|
||
return;
|
||
}
|
||
const TGT = { all:'👥 All', verified:'✅ Verified', unverified:'⏳ Unverified', admins:'🔑 Admins' };
|
||
el.innerHTML = d.broadcasts.map(b => {
|
||
const readPct = b.total_players > 0 ? Math.round((b.read_count/b.total_players)*100) : 0;
|
||
const dt = (b.sent_at||'').substring(0,16).replace('T',' ');
|
||
return `<div class="card" style="margin-bottom:10px">
|
||
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:200px">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:16px;color:var(--text);margin-bottom:4px">${escHtmlA(b.subject)}</div>
|
||
<div style="font-size:14px;color:var(--text2);margin-bottom:8px;line-height:1.5">${escHtmlA(b.message.substring(0,120))}${b.message.length>120?'...':''}</div>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:13px;color:var(--text2)">
|
||
<span style="color:var(--gold);font-weight:700">${TGT[b.target]||b.target}</span>
|
||
<span>📅 ${dt}</span>
|
||
<span>👤 ${escHtmlA(b.sender_name)}</span>
|
||
<span style="color:var(--cyan)">👁 ${b.read_count}/${b.total_players} read (${readPct}%)</span>
|
||
<span style="color:var(--purple)">💬 ${b.reply_count} ${b.reply_count==1?'reply':'replies'}</span>
|
||
</div>
|
||
<div style="margin-top:8px;background:rgba(255,255,255,.08);border-radius:4px;height:5px;width:200px;max-width:100%">
|
||
<div style="height:5px;border-radius:4px;background:var(--cyan);width:${readPct}%"></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px;flex-shrink:0">
|
||
<button onclick="editBroadcast(${b.id},'${escAttr(b.subject)}','${escAttr(b.message)}','${b.target}')"
|
||
style="background:rgba(155,93,229,.1);border:1px solid rgba(155,93,229,.3);color:var(--purple);border-radius:6px;padding:7px 14px;font-size:13px;font-weight:700;cursor:pointer">✏️ Edit</button>
|
||
<button onclick="resendBroadcast(${b.id},'${escAttr(b.subject)}')"
|
||
style="background:rgba(0,229,255,.08);border:1px solid rgba(0,229,255,.2);color:var(--cyan);border-radius:6px;padding:7px 14px;font-size:13px;font-weight:700;cursor:pointer">↩ Resend</button>
|
||
<button onclick="deleteBroadcast(${b.id},'${escAttr(b.subject)}')"
|
||
style="background:rgba(255,68,68,.07);border:1px solid rgba(255,68,68,.2);color:var(--red);border-radius:6px;padding:7px 14px;font-size:13px;font-weight:700;cursor:pointer">🗑 Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function editBroadcast(id, subject, message, target) {
|
||
const newSubject = prompt('Edit Subject:', subject);
|
||
if (newSubject === null) return;
|
||
const newMessage = prompt('Edit Message:', message);
|
||
if (newMessage === null) return;
|
||
const targets = ['all','verified','unverified','admins'];
|
||
const labels = ['All Players','Verified Only','Unverified Only','Admins Only'];
|
||
const tIdx = targets.indexOf(target);
|
||
const newTarget = prompt('Send To (all/verified/unverified/admins):', target);
|
||
if (!targets.includes(newTarget)) { alert('Invalid target. Use: all, verified, unverified, or admins'); return; }
|
||
const d = await apiFetch('broadcast_edit','POST',{id, subject:newSubject, message:newMessage, target:newTarget});
|
||
if (d.success) { toast('Broadcast updated','ok'); loadBroadcasts(); }
|
||
else toast(d.error||'Failed to update','error');
|
||
}
|
||
|
||
async function resendBroadcast(id, subject) {
|
||
if (!confirm('Resend "'+subject+'" to all recipients? This will mark it as unread for everyone.')) return;
|
||
const d = await apiFetch('broadcast_resend','POST',{id});
|
||
if (d.success) { toast('Resent to '+d.recipient_count+' players','ok'); loadBroadcasts(); }
|
||
else toast(d.error||'Failed to resend','error');
|
||
}
|
||
|
||
async function openBcDrawer(id, subject) {
|
||
bcCurrentId = id;
|
||
document.getElementById('bc-drawer').style.display = 'block';
|
||
document.getElementById('bc-drawer-title').textContent = '📢 ' + subject;
|
||
document.getElementById('bc-drawer').scrollIntoView({behavior:'smooth'});
|
||
switchBcTab('replies', document.getElementById('bc-tab-replies'));
|
||
}
|
||
|
||
function closeBcDrawer() {
|
||
document.getElementById('bc-drawer').style.display = 'none';
|
||
bcCurrentId = null;
|
||
}
|
||
|
||
function switchBcTab(tab, btn) {
|
||
bcCurrentTab = tab;
|
||
document.querySelectorAll('#bc-drawer .ftab').forEach(b=>b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
if (tab === 'replies') loadBcReplies();
|
||
else loadBcReads();
|
||
}
|
||
|
||
async function loadBcReplies() {
|
||
if (!bcCurrentId) return;
|
||
const el = document.getElementById('bc-drawer-content');
|
||
el.innerHTML = '<div style="color:var(--text2);font-size:15px;text-align:center;padding:16px">Loading...</div>';
|
||
const d = await apiFetch('broadcast_replies&broadcast_id=' + bcCurrentId);
|
||
if (!d.success || !d.replies.length) {
|
||
el.innerHTML = '<div style="color:var(--text2);font-size:15px;text-align:center;padding:16px">No replies yet.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = d.replies.map(r => {
|
||
const isAdm = parseInt(r.is_admin);
|
||
return `<div style="display:flex;gap:10px;margin-bottom:12px">
|
||
<div style="width:32px;height:32px;border-radius:50%;background:${isAdm?'linear-gradient(135deg,#f0c040,#d4a017)':'linear-gradient(135deg,#7b2fbe,#457b9d)'};display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;color:#fff;flex-shrink:0">${(r.username||'?').charAt(0).toUpperCase()}</div>
|
||
<div style="flex:1;background:${isAdm?'rgba(240,192,64,.07)':'var(--bg3)'};border:1px solid ${isAdm?'rgba(240,192,64,.2)':'var(--border)'};border-radius:8px;padding:8px 12px">
|
||
<div style="font-size:15px;font-weight:700;color:${isAdm?'var(--gold)':'var(--cyan)'};margin-bottom:4px">
|
||
${escHtmlA(r.username)}${isAdm?' 🔑 (Admin)':' · '+escHtmlA(r.alias)} <span style="color:var(--text2);font-weight:400">· ${(r.created_at||'').substring(0,16)}</span>
|
||
</div>
|
||
<div style="font-size:15px;color:var(--text)">${escHtmlA(r.message)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadBcReads() {
|
||
if (!bcCurrentId) return;
|
||
const el = document.getElementById('bc-drawer-content');
|
||
el.innerHTML = '<div style="color:var(--text2);font-size:15px;text-align:center;padding:16px">Loading...</div>';
|
||
const d = await apiFetch('broadcast_reads&broadcast_id=' + bcCurrentId);
|
||
if (!d.success || !d.reads.length) {
|
||
el.innerHTML = '<div style="color:var(--text2);font-size:15px;text-align:center;padding:16px">No reads yet.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:8px">${d.reads.map(r=>`
|
||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:14px">
|
||
<span style="font-weight:700;color:var(--text)">${escHtmlA(r.username)}</span>
|
||
<span style="color:var(--text2)"> · ${(r.read_at||'').substring(0,16)}</span>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
async function adminBroadcastReply() {
|
||
if (!bcCurrentId) return;
|
||
const input = document.getElementById('bc-reply-input');
|
||
const msg = input.value.trim();
|
||
if (!msg) return;
|
||
input.value = '';
|
||
const d = await apiFetch('broadcast_reply','POST',{broadcast_id:bcCurrentId, message:msg});
|
||
if (d.success) { loadBcReplies(); loadBroadcasts(); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
async function deleteBroadcast(id, subject) {
|
||
if (!confirm('Delete broadcast "' + subject + '"?\n\nThis will remove all replies and read receipts. Cannot be undone.')) return;
|
||
const d = await apiFetch('broadcast_delete','POST',{id});
|
||
if (d.success) { toast('Broadcast deleted','ok'); loadBroadcasts(); if (bcCurrentId===id) closeBcDrawer(); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// ─── CASHOUT METHOD TYPES ─────────────────────────────────
|
||
async function loadCashoutMethods() {
|
||
const d = await apiFetch('cashout_methods_list');
|
||
const el = document.getElementById('cmt-list');
|
||
const ct = document.getElementById('cmt-count');
|
||
if (!d.success) { el.innerHTML = '<div class="empty">Failed to load.</div>'; return; }
|
||
const types = d.types || [];
|
||
if (ct) ct.textContent = types.length + ' method' + (types.length !== 1 ? 's' : '');
|
||
if (!types.length) { el.innerHTML = '<div class="empty" style="padding:24px;text-align:center">No methods yet. Add one above.</div>'; return; }
|
||
window._cmtData = types;
|
||
el.innerHTML = types.map(m => `
|
||
<div class="game-row${!parseInt(m.is_active)?' inactive-overlay':''}">
|
||
<div style="font-size:26px;flex-shrink:0">${escHtmlA(m.icon||'💰')}</div>
|
||
<div class="game-info">
|
||
<div class="game-name">${escHtmlA(m.label)}
|
||
${!parseInt(m.is_active)?'<span class="badge" style="background:rgba(255,68,68,.15);color:var(--red);font-size:15px;margin-left:6px">INACTIVE</span>':''}
|
||
</div>
|
||
<div class="game-slug">${escHtmlA(m.slug)}</div>
|
||
${m.description?`<div style="font-size:14px;color:var(--text2);margin-top:2px">${escHtmlA(m.description)}</div>`:''}
|
||
</div>
|
||
<div style="text-align:right;flex-shrink:0">
|
||
<div style="font-size:15px;color:var(--text2);margin-bottom:6px">Order: ${m.sort_order}</div>
|
||
<div class="game-actions">
|
||
<button class="game-edit-btn" style="background:rgba(0,229,255,.1);color:var(--cyan);border:1px solid rgba(0,229,255,.2)" onclick="editCashoutMethod(${m.id})">✏️ Edit</button>
|
||
<button class="game-edit-btn" style="background:rgba(255,68,68,.1);color:var(--red);border:1px solid rgba(255,68,68,.2)" onclick="deleteCashoutMethod(${m.id},'${escAttr(m.label)}')">🗑</button>
|
||
</div>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function editCashoutMethod(id) {
|
||
const m = (window._cmtData||[]).find(x=>x.id==id);
|
||
if (!m) return;
|
||
document.getElementById('cmt-id').value = m.id;
|
||
document.getElementById('cmt-label').value = m.label;
|
||
document.getElementById('cmt-slug').value = m.slug;
|
||
document.getElementById('cmt-slug').disabled = true;
|
||
document.getElementById('cmt-icon').value = m.icon || '💰';
|
||
document.getElementById('cmt-desc').value = m.description || '';
|
||
document.getElementById('cmt-sort').value = m.sort_order;
|
||
document.getElementById('cmt-active').value= m.is_active;
|
||
document.getElementById('cmt-form-title').textContent = '✏️ Editing: ' + m.label;
|
||
document.getElementById('cmt-form-card').scrollIntoView({behavior:'smooth'});
|
||
}
|
||
|
||
function resetCmtForm() {
|
||
['cmt-id','cmt-label','cmt-desc'].forEach(id => document.getElementById(id).value = '');
|
||
document.getElementById('cmt-slug').value = '';
|
||
document.getElementById('cmt-slug').disabled = false;
|
||
document.getElementById('cmt-icon').value = '💰';
|
||
document.getElementById('cmt-sort').value = '99';
|
||
document.getElementById('cmt-active').value = '1';
|
||
document.getElementById('cmt-form-title').textContent = '➕ Add Cashout Method';
|
||
document.getElementById('cmt-form-alert').className = 'alert';
|
||
}
|
||
|
||
async function saveCashoutMethod() {
|
||
const al = document.getElementById('cmt-form-alert');
|
||
const id = document.getElementById('cmt-id').value;
|
||
const data = {
|
||
id: id ? parseInt(id) : undefined,
|
||
label: document.getElementById('cmt-label').value.trim(),
|
||
slug: document.getElementById('cmt-slug').value.trim(),
|
||
icon: document.getElementById('cmt-icon').value.trim() || '💰',
|
||
description: document.getElementById('cmt-desc').value.trim(),
|
||
sort_order: parseInt(document.getElementById('cmt-sort').value) || 99,
|
||
is_active: parseInt(document.getElementById('cmt-active').value),
|
||
};
|
||
if (!data.label) { showAdminAlert(al,'Label is required.','error'); return; }
|
||
if (!id && !data.slug) { showAdminAlert(al,'Slug is required for new methods.','error'); return; }
|
||
const action = id ? 'cashout_methods_update' : 'cashout_methods_create';
|
||
const d = await apiFetch(action,'POST',data);
|
||
if (d.success) {
|
||
showAdminAlert(al, id ? 'Method updated!' : 'Method added!', 'success');
|
||
resetCmtForm();
|
||
loadCashoutMethods();
|
||
toast(id ? 'Updated' : 'Method added', 'ok');
|
||
} else showAdminAlert(al, d.error||'Save failed','error');
|
||
}
|
||
|
||
async function deleteCashoutMethod(id, label) {
|
||
if (!confirm('Delete cashout method "' + label + '"?\n\nPlayers using this method will still have their saved payout methods, but it won\'t appear as an option for new methods.')) return;
|
||
const d = await apiFetch('cashout_methods_delete','POST',{id});
|
||
if (d.success) { toast('Deleted','ok'); loadCashoutMethods(); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// ─── GAME MANAGEMENT ──────────────────────────────────────
|
||
async function loadGames() {
|
||
const d = await apiFetch('platforms_admin');
|
||
const el = document.getElementById('games-list');
|
||
const ct = document.getElementById('games-count');
|
||
if (!d.success) { el.innerHTML = '<div class="empty">Failed to load games.</div>'; return; }
|
||
const games = d.platforms;
|
||
ct.textContent = games.length + ' game' + (games.length !== 1 ? 's' : '');
|
||
if (!games.length) { el.innerHTML = '<div class="empty" style="padding:24px;text-align:center">No games yet. Add one above.</div>'; return; }
|
||
el.innerHTML = games.map(g => `
|
||
<div class="game-row${!parseInt(g.is_active)?' inactive-overlay':''}">
|
||
<div class="game-color-dot" style="background:${g.color};color:${g.color}"></div>
|
||
<div class="game-info">
|
||
<div class="game-name">${escHtmlA(g.name)}
|
||
${!parseInt(g.is_active) ? '<span class="badge" style="background:rgba(255,68,68,.15);color:var(--red);font-size:15px;margin-left:6px">INACTIVE</span>' : ''}
|
||
</div>
|
||
<div class="game-slug">${escHtmlA(g.slug)}</div>
|
||
<div class="game-urls">
|
||
<div class="game-url-row">
|
||
<span class="game-url-label">PLAYER</span>
|
||
<span class="game-url-val">${escHtmlA(g.player_url)}</span>
|
||
<a href="${escHtmlA(g.player_url)}" target="_blank" class="game-url-link">↗</a>
|
||
</div>
|
||
${g.console_url ? `<div class="game-url-row">
|
||
<span class="game-url-label" style="color:var(--gold)">CONSOLE</span>
|
||
<span class="game-url-val">${escHtmlA(g.console_url)}</span>
|
||
<a href="${escHtmlA(g.console_url)}" target="_blank" class="game-url-link" style="color:var(--gold)">↗</a>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="text-align:right;flex-shrink:0">
|
||
<div style="font-size:15px;color:var(--text2);margin-bottom:6px">Order: ${g.sort_order}</div>
|
||
<div class="game-actions">
|
||
<button class="game-edit-btn" style="background:rgba(0,229,255,.1);color:var(--cyan);border:1px solid rgba(0,229,255,.2)" onclick="editGame(${g.id})">✏️ Edit</button>
|
||
<button class="game-edit-btn" style="background:rgba(255,68,68,.1);color:var(--red);border:1px solid rgba(255,68,68,.2)" onclick="deleteGame(${g.id},'${escAttr(g.name)}')">🗑</button>
|
||
</div>
|
||
</div>
|
||
</div>`).join('');
|
||
// Store for edit
|
||
window._gamesData = games;
|
||
}
|
||
|
||
function editGame(id) {
|
||
const g = (window._gamesData || []).find(x => x.id == id);
|
||
if (!g) return;
|
||
document.getElementById('gf-id').value = g.id;
|
||
document.getElementById('gf-name').value = g.name;
|
||
document.getElementById('gf-slug').value = g.slug;
|
||
document.getElementById('gf-slug').disabled = true; // slug can't change (used as FK in purchases)
|
||
document.getElementById('gf-player-url').value = g.player_url;
|
||
document.getElementById('gf-console-url').value= g.console_url || '';
|
||
document.getElementById('gf-color').value = g.color || '#f0c040';
|
||
document.getElementById('gf-color-hex').value = g.color || '#f0c040';
|
||
document.getElementById('gf-sort').value = g.sort_order;
|
||
document.getElementById('gf-active').value = g.is_active;
|
||
document.getElementById('game-form-title').textContent = '✏️ Editing: ' + g.name;
|
||
document.getElementById('game-form-card').scrollIntoView({behavior:'smooth'});
|
||
}
|
||
|
||
function resetGameForm() {
|
||
document.getElementById('gf-id').value = '';
|
||
document.getElementById('gf-name').value = '';
|
||
document.getElementById('gf-slug').value = '';
|
||
document.getElementById('gf-slug').disabled = false;
|
||
document.getElementById('gf-player-url').value = '';
|
||
document.getElementById('gf-console-url').value = '';
|
||
document.getElementById('gf-color').value = '#f0c040';
|
||
document.getElementById('gf-color-hex').value = '#f0c040';
|
||
document.getElementById('gf-sort').value = '99';
|
||
document.getElementById('gf-active').value = '1';
|
||
document.getElementById('game-form-title').textContent = '➕ Add New Game';
|
||
document.getElementById('game-form-alert').className = 'alert';
|
||
}
|
||
|
||
function syncColorPicker(hex) {
|
||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) document.getElementById('gf-color').value = hex;
|
||
}
|
||
|
||
async function saveGame() {
|
||
const al = document.getElementById('game-form-alert');
|
||
const id = document.getElementById('gf-id').value;
|
||
const data = {
|
||
id: id ? parseInt(id) : undefined,
|
||
name: document.getElementById('gf-name').value.trim(),
|
||
slug: document.getElementById('gf-slug').value.trim(),
|
||
player_url: document.getElementById('gf-player-url').value.trim(),
|
||
console_url: document.getElementById('gf-console-url').value.trim(),
|
||
color: document.getElementById('gf-color-hex').value.trim() || document.getElementById('gf-color').value,
|
||
sort_order: parseInt(document.getElementById('gf-sort').value) || 99,
|
||
is_active: parseInt(document.getElementById('gf-active').value),
|
||
};
|
||
if (!data.name || !data.player_url) { showAdminAlert(al,'Name and Player URL are required.','error'); return; }
|
||
if (!id && !data.slug) { showAdminAlert(al,'Slug is required for new games.','error'); return; }
|
||
const action = id ? 'platforms_update' : 'platforms_create';
|
||
const d = await apiFetch(action, 'POST', data);
|
||
if (d.success) {
|
||
showAdminAlert(al, id ? 'Game updated!' : 'Game added!', 'success');
|
||
resetGameForm();
|
||
loadGames();
|
||
toast(id ? 'Game updated' : 'Game added', 'ok');
|
||
} else {
|
||
showAdminAlert(al, d.error || 'Save failed.', 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteGame(id, name) {
|
||
if (!confirm('Delete game "' + name + '"? This cannot be undone.\n\nNote: existing purchase records referencing this game will still show the old platform ID.')) return;
|
||
const d = await apiFetch('platforms_delete','POST',{id});
|
||
if (d.success) { toast('Game deleted','ok'); loadGames(); }
|
||
else toast(d.error||'Error','err');
|
||
}
|
||
|
||
// Sync hex input with color picker
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const picker = document.getElementById('gf-color');
|
||
const hex = document.getElementById('gf-color-hex');
|
||
if (picker && hex) {
|
||
picker.addEventListener('input', function() { hex.value = picker.value; });
|
||
}
|
||
});
|
||
|
||
// ─── COMPOSE: send message to any player ──────────────────
|
||
let composePlayer = null; // { id, username, alias, email }
|
||
|
||
function searchComposePlayers(q) {
|
||
const dd = document.getElementById('compose-dropdown');
|
||
if (!q || q.length < 1) { dd.style.display = 'none'; return; }
|
||
const lq = q.toLowerCase();
|
||
const matches = (allUsers.length ? allUsers : GM.all || []).filter(u =>
|
||
u.username.toLowerCase().includes(lq) ||
|
||
(u.alias||'').toLowerCase().includes(lq) ||
|
||
(u.email||'').toLowerCase().includes(lq)
|
||
).filter(u => !u.is_admin).slice(0, 8);
|
||
|
||
if (!matches.length) {
|
||
dd.innerHTML = '<div style="padding:12px;color:var(--text2);font-size:15px;text-align:center">No players found</div>';
|
||
dd.style.display = 'block'; return;
|
||
}
|
||
dd.innerHTML = matches.map(u => {
|
||
const color = avatarColor(u.username);
|
||
const init = avatarInit(u.username);
|
||
return `<div onclick="selectComposePicker(${u.id},'${escAttr(u.username)}','${escAttr(u.alias||'')}','${escAttr(u.email||'')}','${color}')"
|
||
style="display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;transition:background .1s"
|
||
onmouseover="this.style.background='rgba(255,255,255,.04)'" onmouseout="this.style.background='none'">
|
||
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,${color},${color}99);display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:700;font-size:14px;color:#fff;flex-shrink:0">${init}</div>
|
||
<div style="min-width:0">
|
||
<div style="font-weight:700;font-size:15px;color:var(--text)">${escHtmlA(u.username)} <span style="color:var(--cyan);font-weight:400">· ${escHtmlA(u.alias||'')}</span></div>
|
||
<div style="font-size:15px;color:var(--text2)">${escHtmlA(u.email||'No email')} · ${u.tokens||0} 🪙</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
dd.style.display = 'block';
|
||
}
|
||
|
||
function selectComposePicker(id, username, alias, email, color) {
|
||
composePlayer = { id, username, alias, email };
|
||
const sel = document.getElementById('compose-selected');
|
||
const av = document.getElementById('compose-player-avatar');
|
||
const nm = document.getElementById('compose-player-name');
|
||
const inf = document.getElementById('compose-player-info');
|
||
av.textContent = username.charAt(0).toUpperCase();
|
||
av.style.background = `linear-gradient(135deg,${color},${color}99)`;
|
||
nm.textContent = username + ' · ' + alias;
|
||
inf.textContent = email || 'No email';
|
||
sel.style.display = 'flex';
|
||
document.getElementById('compose-search').value = '';
|
||
document.getElementById('compose-dropdown').style.display = 'none';
|
||
document.getElementById('compose-msg').focus();
|
||
}
|
||
|
||
function clearComposePicker() {
|
||
composePlayer = null;
|
||
document.getElementById('compose-selected').style.display = 'none';
|
||
document.getElementById('compose-search').value = '';
|
||
document.getElementById('compose-alert').className = 'alert';
|
||
}
|
||
|
||
async function adminComposeSend() {
|
||
const al = document.getElementById('compose-alert');
|
||
const msg = document.getElementById('compose-msg').value.trim();
|
||
if (!composePlayer) { showAdminAlert(al, 'Select a player first.', 'error'); return; }
|
||
if (!msg) { showAdminAlert(al, 'Type a message first.', 'error'); return; }
|
||
|
||
const d = await apiFetch('chat_admin_send', 'POST', { user_id: composePlayer.id, message: msg });
|
||
if (d.success) {
|
||
document.getElementById('compose-msg').value = '';
|
||
showAdminAlert(al, '✓ Message sent to ' + composePlayer.username + '! They will see it in their Support chat.', 'success');
|
||
loadChatInbox(true); // refresh inbox
|
||
loadChatBadge();
|
||
// Optional: open the thread immediately
|
||
setTimeout(() => {
|
||
openChatThread(composePlayer.id, composePlayer.username, composePlayer.alias);
|
||
}, 800);
|
||
} else {
|
||
showAdminAlert(al, d.error || 'Failed to send.', 'error');
|
||
}
|
||
}
|
||
|
||
// Close compose dropdown when clicking outside
|
||
document.addEventListener('click', function(e) {
|
||
const dd = document.getElementById('compose-dropdown');
|
||
const search = document.getElementById('compose-search');
|
||
if (dd && !dd.contains(e.target) && e.target !== search) {
|
||
dd.style.display = 'none';
|
||
}
|
||
});
|
||
async function loadPendingSignups() {
|
||
const d = await apiFetch('pending_signups');
|
||
const el = document.getElementById('pending-list');
|
||
if (!d.success || !d.pending.length) {
|
||
el.innerHTML = '<div class="card"><div class="empty" style="padding:24px;text-align:center">✅ No pending registrations.</div></div>';
|
||
document.getElementById('badge-pending').style.display = 'none';
|
||
return;
|
||
}
|
||
const badge = document.getElementById('badge-pending');
|
||
if (badge) { badge.textContent = d.pending.length; badge.style.display = 'inline'; }
|
||
|
||
el.innerHTML = d.pending.map(p => {
|
||
const color = avatarColor(p.username);
|
||
const init = avatarInit(p.username);
|
||
const created = (p.created_at||'').substring(0,16);
|
||
const expires = (p.expires_at||'').substring(0,16);
|
||
const now = new Date();
|
||
const expDate = new Date(p.expires_at);
|
||
const expiring = (expDate - now) < 3600000; // < 1hr
|
||
return `
|
||
<div class="card" style="margin-bottom:10px;display:flex;align-items:center;gap:14px;flex-wrap:wrap">
|
||
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,${color},${color}99);display:flex;align-items:center;justify-content:center;font-family:'Exo 2',sans-serif;font-weight:700;font-size:18px;color:#fff;flex-shrink:0">${init}</div>
|
||
<div style="flex:1;min-width:180px">
|
||
<div style="font-family:'Exo 2',sans-serif;font-weight:700;font-size:15px;color:var(--text)">${escHtmlA(p.username)}</div>
|
||
<div style="font-size:14px;color:var(--cyan);margin-top:1px">${escHtmlA(p.alias)}</div>
|
||
<div style="font-size:15px;color:var(--text2);margin-top:2px">${escHtmlA(p.email||'No email')}</div>
|
||
<div style="font-size:14px;margin-top:4px;display:flex;gap:10px;flex-wrap:wrap">
|
||
<span style="color:var(--text2)">Registered: ${created}</span>
|
||
<span style="color:${expiring?'var(--red)':'var(--text2)'}">Expires: ${expires}${expiring?' ⚠️':''}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||
<button class="btn btn-green" style="padding:8px 16px;font-size:14px" onclick="approvePending(${p.id},'${escAttr(p.username)}')">
|
||
✓ Approve
|
||
</button>
|
||
<button class="btn btn-red" style="padding:8px 16px;font-size:14px" onclick="deletePending(${p.id},'${escAttr(p.username)}')">
|
||
✗ Delete
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function approvePending(id, username) {
|
||
if (!confirm('Approve ' + username + '?\n\nThis will create their account immediately and bypass email verification.')) return;
|
||
const d = await apiFetch('approve_pending', 'POST', { id });
|
||
if (d.success) {
|
||
toast('✓ ' + d.username + ' approved and account created!', 'ok');
|
||
loadPendingSignups();
|
||
loadStats();
|
||
loadUsers();
|
||
} else {
|
||
toast(d.error || 'Error approving signup', 'err');
|
||
}
|
||
}
|
||
|
||
async function deletePending(id, username) {
|
||
if (!confirm('Delete pending signup for ' + username + '?\n\nThey will need to register again.')) return;
|
||
const d = await apiFetch('delete_pending', 'POST', { id });
|
||
if (d.success) { toast('Deleted', 'ok'); loadPendingSignups(); loadStats(); }
|
||
else toast('Error', 'err');
|
||
}
|
||
|
||
// ─── ADMIN CHAT ────────────────────────────────────────────
|
||
let adminChat = { userId: null, lastId: 0, polling: null, inboxPolling: null };
|
||
|
||
function showSec(name) {
|
||
document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active'));
|
||
const sec = document.getElementById('section-'+name);
|
||
if (sec) sec.classList.add('active');
|
||
// Find the matching nav button safely
|
||
document.querySelectorAll('.nav-item').forEach(b => {
|
||
if (b.getAttribute('onclick') && b.getAttribute('onclick').includes("'"+name+"'")) {
|
||
b.classList.add('active');
|
||
}
|
||
});
|
||
if (name === 'chat') {
|
||
showChatInbox();
|
||
loadChatInbox();
|
||
// Auto-refresh inbox every 5s when inbox is visible
|
||
clearInterval(adminChat.inboxPolling);
|
||
adminChat.inboxPolling = setInterval(() => {
|
||
if (!adminChat.userId) { loadChatInbox(true); } // silent refresh
|
||
}, 5000);
|
||
} else {
|
||
clearInterval(adminChat.inboxPolling);
|
||
}
|
||
if (name === 'pending') loadPendingSignups();
|
||
if (name === 'history') loadHistory(1);
|
||
if (name === 'users') { loadUsers(); showGamerList(); }
|
||
if (name === 'referrals') { loadAdminReferrals('pending', document.querySelector('#section-referrals .ftab')); }
|
||
if (name === 'platform-accounts') loadPlatformAccountRequests('pending', document.querySelector('#section-platform-accounts .ftab'));
|
||
if (name === 'broadcasts') loadBroadcasts();
|
||
if (name === 'games') loadGames();
|
||
if (name === 'payments') loadPaymentSettings();
|
||
if (name === 'payout-settings') loadPayoutSettings();
|
||
if (name === 'cashout-methods') loadCashoutMethods();
|
||
}
|
||
|
||
async function loadChatInbox(silent) {
|
||
const el = document.getElementById('chat-inbox-list');
|
||
if (!silent) el.innerHTML = '<div class="empty" style="padding:24px">Loading conversations...</div>';
|
||
const d = await apiFetch('chat_inbox');
|
||
if (!d.success) return;
|
||
if (!d.inbox.length) {
|
||
el.innerHTML = '<div class="empty" style="padding:30px;text-align:center">💬 No messages yet.<br><span style="color:var(--text2);font-size:14px">Users will appear here when they send a message.</span></div>';
|
||
return;
|
||
}
|
||
// Update unread badge in nav
|
||
const totalUnread = d.inbox.reduce((s,r) => s + parseInt(r.unread_count||0), 0);
|
||
const badge = document.getElementById('badge-chat');
|
||
if (badge) { badge.textContent = totalUnread > 9 ? '9+' : totalUnread; badge.style.display = totalUnread > 0 ? 'inline' : 'none'; }
|
||
|
||
el.innerHTML = d.inbox.map(row => {
|
||
const init = (row.username||'?').charAt(0).toUpperCase();
|
||
const unread = parseInt(row.unread_count) > 0;
|
||
const preview = (row.last_sender === 'admin' ? 'You: ' : '') + (row.last_message || '').substring(0, 65);
|
||
const time = formatAdminTime(row.last_time);
|
||
return `<div class="chat-inbox-row${unread?' unread':''}" onclick="openChatThread(${row.user_id},'${escAttr(row.username)}','${escAttr(row.alias)}')">
|
||
<div class="ci-avatar" style="background:linear-gradient(135deg,${avatarColor(row.username)},${avatarColor(row.username)}aa)">${init}</div>
|
||
<div class="ci-body">
|
||
<div class="ci-top">
|
||
<div class="ci-name">${escHtmlA(row.username)} <span style="color:var(--cyan);font-weight:400;font-size:14px">· ${escHtmlA(row.alias)}</span></div>
|
||
<div class="ci-time">${time}</div>
|
||
</div>
|
||
<div class="ci-preview${unread?' unread-preview':''}">${escHtmlA(preview)}</div>
|
||
</div>
|
||
${unread ? `<div class="ci-badge">${row.unread_count > 9 ? '9+' : row.unread_count}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function showChatInbox() {
|
||
document.getElementById('chat-inbox-view').style.display = 'block';
|
||
document.getElementById('chat-thread-view').style.display = 'none';
|
||
clearInterval(adminChat.polling);
|
||
adminChat.userId = null;
|
||
adminChat.lastId = 0;
|
||
}
|
||
|
||
async function openChatThread(userId, username, alias) {
|
||
clearInterval(adminChat.inboxPolling);
|
||
adminChat.userId = userId;
|
||
adminChat.lastId = 0;
|
||
document.getElementById('chat-inbox-view').style.display = 'none';
|
||
document.getElementById('chat-thread-view').style.display = 'block';
|
||
document.getElementById('admin-chat-name').textContent = username + ' · ' + alias;
|
||
document.getElementById('admin-chat-meta').textContent = 'Loading...';
|
||
document.getElementById('admin-chat-avatar').textContent = username.charAt(0).toUpperCase();
|
||
document.getElementById('admin-chat-messages').innerHTML = '<div style="color:var(--text2);font-size:14px;text-align:center;padding:20px">Loading...</div>';
|
||
await loadAdminThread(true);
|
||
clearInterval(adminChat.polling);
|
||
adminChat.polling = setInterval(() => loadAdminThread(false), 3000);
|
||
}
|
||
|
||
async function loadAdminThread(scrollToBottom) {
|
||
if (!adminChat.userId) return;
|
||
const d = await apiFetch('chat_thread&user_id=' + adminChat.userId + '&since=' + adminChat.lastId);
|
||
if (!d.success) return;
|
||
const container = document.getElementById('admin-chat-messages');
|
||
|
||
if (d.messages.length === 0 && adminChat.lastId === 0) {
|
||
container.innerHTML = '<div style="color:var(--text2);font-size:15px;text-align:center;padding:24px">No messages in this thread yet.</div>';
|
||
}
|
||
|
||
if (d.messages.length > 0) {
|
||
if (adminChat.lastId === 0) container.innerHTML = '';
|
||
let lastDate = '';
|
||
d.messages.forEach(msg => {
|
||
if (container.querySelector('[data-id="' + msg.id + '"]')) return;
|
||
const msgDate = msg.created_at.substring(0,10);
|
||
if (msgDate !== lastDate) {
|
||
lastDate = msgDate;
|
||
const div = document.createElement('div');
|
||
div.className = 'admin-date-div';
|
||
div.textContent = formatAdminDateLabel(msgDate);
|
||
container.appendChild(div);
|
||
}
|
||
const bubble = buildAdminBubble(msg);
|
||
if (msg.sender === 'user' && adminChat.lastId > 0) {
|
||
bubble.style.animation = 'fadeUp .25s ease';
|
||
}
|
||
container.appendChild(bubble);
|
||
adminChat.lastId = Math.max(adminChat.lastId, parseInt(msg.id));
|
||
});
|
||
if (scrollToBottom || (container.scrollHeight - container.scrollTop - container.clientHeight < 160)) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
}
|
||
if (d.user) {
|
||
document.getElementById('admin-chat-meta').textContent = 'Alias: ' + d.user.alias + ' · ' + d.user.tokens + ' tokens';
|
||
}
|
||
}
|
||
|
||
function buildAdminBubble(msg) {
|
||
const isAdmin = msg.sender === 'admin';
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'admin-bubble-wrap ' + (isAdmin ? 'from-admin' : 'from-user');
|
||
wrap.dataset.id = msg.id;
|
||
const time = msg.created_at.substring(11,16);
|
||
wrap.innerHTML =
|
||
'<div class="admin-bubble ' + (isAdmin ? 'from-admin' : 'from-user') + '">' + escHtmlA(msg.message) + '</div>' +
|
||
'<div class="admin-bubble-time">' + (isAdmin ? 'You · ' : 'User · ') + time + '</div>';
|
||
return wrap;
|
||
}
|
||
|
||
async function adminClearThread() {
|
||
if (!adminChat.userId) return;
|
||
const name = document.getElementById('admin-chat-name')?.textContent || 'this player';
|
||
if (!confirm('Clear all messages with ' + name + '?\n\nThis permanently deletes the entire conversation and cannot be undone.')) return;
|
||
const d = await apiFetch('chat_clear_thread', 'POST', { user_id: adminChat.userId });
|
||
if (d.success) {
|
||
document.getElementById('admin-chat-messages').innerHTML =
|
||
'<div style="color:var(--text2);font-size:15px;text-align:center;padding:24px">Conversation cleared.</div>';
|
||
adminChat.lastId = 0;
|
||
toast('Thread cleared', 'ok');
|
||
loadChatBadge();
|
||
} else toast(d.error || 'Error', 'err');
|
||
}
|
||
|
||
async function adminClearAllChats() {
|
||
if (!confirm('Clear ALL chat messages from ALL players?\n\nThis permanently deletes every conversation and cannot be undone.')) return;
|
||
if (!confirm('Are you absolutely sure? This will remove all chat history for every player.')) return;
|
||
const d = await apiFetch('chat_clear_all', 'POST', {});
|
||
if (d.success) {
|
||
toast('All chats cleared', 'ok');
|
||
showChatInbox();
|
||
loadChatInbox();
|
||
loadChatBadge();
|
||
} else toast(d.error || 'Error', 'err');
|
||
}
|
||
|
||
async function adminSendMsg() {
|
||
const input = document.getElementById('admin-chat-input');
|
||
const msg = input.value.trim();
|
||
if (!msg || !adminChat.userId) return;
|
||
input.value = '';
|
||
|
||
const container = document.getElementById('admin-chat-messages');
|
||
const tempWrap = buildAdminBubble({ id: 'temp_' + Date.now(), sender:'admin', message:msg, created_at: new Date().toISOString().replace('T',' ').substring(0,19) });
|
||
tempWrap.style.opacity = '0.65';
|
||
container.appendChild(tempWrap);
|
||
container.scrollTop = container.scrollHeight;
|
||
|
||
const d = await apiFetch('chat_admin_send','POST',{ user_id: adminChat.userId, message: msg });
|
||
if (d.success) {
|
||
tempWrap.style.opacity = '1';
|
||
tempWrap.dataset.id = d.id;
|
||
adminChat.lastId = Math.max(adminChat.lastId, d.id);
|
||
loadChatBadge();
|
||
} else {
|
||
tempWrap.remove();
|
||
input.value = msg;
|
||
toast('Send failed','err');
|
||
}
|
||
}
|
||
|
||
async function loadChatBadge() {
|
||
const d = await apiFetch('chat_unread');
|
||
if (d.success) {
|
||
const b = document.getElementById('badge-chat');
|
||
const alert = document.getElementById('unread-chat-alert');
|
||
const count = document.getElementById('unread-chat-alert-count');
|
||
const text = document.getElementById('unread-chat-alert-text');
|
||
if (b) { b.textContent = d.count; b.style.display = d.count > 0 ? 'inline-block' : 'none'; }
|
||
if (alert) alert.style.display = d.count > 0 ? 'flex' : 'none';
|
||
if (count) count.textContent = d.count;
|
||
if (text) text.textContent = d.count + ' unread message' + (d.count !== 1 ? 's' : '') + ' waiting for a response';
|
||
}
|
||
}
|
||
|
||
function formatAdminTime(dt) {
|
||
if (!dt) return '';
|
||
const d = new Date(dt.replace(' ','T'));
|
||
const now = new Date();
|
||
const diff = (now - d) / 1000;
|
||
if (diff < 60) return 'Just now';
|
||
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
|
||
if (diff < 86400) return d.toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'});
|
||
return d.toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
||
}
|
||
|
||
function formatAdminDateLabel(dateStr) {
|
||
const d = new Date(dateStr + 'T00:00:00');
|
||
const today = new Date(); today.setHours(0,0,0,0);
|
||
const diff = Math.round((today - d) / 86400000);
|
||
if (diff === 0) return 'Today';
|
||
if (diff === 1) return 'Yesterday';
|
||
return d.toLocaleDateString('en-US',{weekday:'long',month:'short',day:'numeric'});
|
||
}
|
||
|
||
function escHtmlA(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>'); }
|
||
function escAttr(s) { return String(s||'').replace(/'/g,"\\'").replace(/"/g,'"'); }
|
||
|
||
// Poll chat badge every 10s
|
||
setInterval(loadChatBadge, 10000);
|
||
loadChatBadge();
|
||
|
||
</script>
|
||
<div style="position:fixed;bottom:8px;right:12px;font-size:14px;color:rgba(255,255,255,.2);font-family:'Exo 2',sans-serif;pointer-events:none;z-index:999">
|
||
TomTomGames Admin v1.0.0
|
||
</div>
|
||
</body>
|
||
</html>
|