Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>HD Remover β Admin Dashboard</title> | |
| <style> | |
| :root { | |
| --bg: #0d0f14; | |
| --surface: #161b25; | |
| --border: #252d3d; | |
| --accent: #00e5a0; | |
| --accent2: #5b6ef5; | |
| --danger: #ff4f6b; | |
| --warn: #ffc850; | |
| --text: #e8eaf0; | |
| --muted: #7a8499; | |
| --radius: 8px; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; } | |
| /* ββ Login ββ */ | |
| #login-screen { display: flex; align-items: center; justify-content: center; min-height: 100vh; } | |
| .login-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 40px 36px; width: 360px; text-align: center; } | |
| .login-card h1 { font-size: 22px; font-weight: 700; color: var(--accent); margin-bottom: 6px; } | |
| .login-card p { color: var(--muted); font-size: 13px; margin-bottom: 28px; } | |
| .login-err { color: var(--danger); font-size: 13px; margin-top: 10px; min-height: 18px; } | |
| /* ββ Layout ββ */ | |
| #app { display: none; } | |
| .topbar { background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px; display: flex; align-items: center; gap: 16px; height: 52px; } | |
| .topbar .logo { font-size: 16px; font-weight: 700; color: var(--accent); } | |
| .topbar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); display: inline-block; margin-right: 4px; animation: pulse 2s infinite; } | |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} } | |
| .topbar .spacer { flex: 1; } | |
| .topbar .health-badge { font-size: 12px; color: var(--muted); } | |
| .main { display: flex; height: calc(100vh - 52px); } | |
| .sidebar { width: 200px; background: var(--surface); border-right: 1px solid var(--border); padding: 16px 0; flex-shrink: 0; } | |
| .nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; color: var(--muted); font-size: 13px; border-left: 3px solid transparent; transition: all .15s; } | |
| .nav-item:hover { color: var(--text); background: rgba(255,255,255,.03); } | |
| .nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(0,229,160,.05); } | |
| .nav-icon { font-size: 16px; width: 18px; text-align: center; } | |
| .content { flex: 1; overflow-y: auto; padding: 24px; } | |
| .page { display: none; } | |
| .page.active { display: block; } | |
| /* ββ Cards ββ */ | |
| .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; } | |
| .card-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 16px; } | |
| .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 20px; } | |
| .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; } | |
| .stat-val { font-size: 28px; font-weight: 700; color: var(--accent); } | |
| .stat-label { font-size: 12px; color: var(--muted); margin-top: 4px; } | |
| /* ββ Table ββ */ | |
| .tbl-wrap { overflow-x: auto; } | |
| table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| th { text-align: left; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; padding: 8px 12px; border-bottom: 1px solid var(--border); } | |
| td { padding: 10px 12px; border-bottom: 1px solid rgba(37,45,61,.6); vertical-align: middle; } | |
| tr:hover td { background: rgba(255,255,255,.02); } | |
| .plan-badge { padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; text-transform: uppercase; } | |
| .plan-free { background: rgba(122,132,153,.15); color: var(--muted); } | |
| .plan-starter { background: rgba(91,110,245,.2); color: var(--accent2); } | |
| .plan-pro { background: rgba(0,229,160,.15); color: var(--accent); } | |
| .plan-master { background: rgba(255,200,80,.15); color: var(--warn); } | |
| .key-mono { font-family: monospace; font-size: 12px; color: var(--muted); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } | |
| .key-mono:hover { color: var(--accent); } | |
| .action-btn { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s; } | |
| .action-btn:hover { border-color: var(--accent); color: var(--accent); } | |
| .action-btn.danger:hover { border-color: var(--danger); color: var(--danger); } | |
| .usage-bar { width: 80px; height: 6px; background: rgba(255,255,255,.1); border-radius: 3px; overflow: hidden; } | |
| .usage-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width .3s; } | |
| .usage-fill.warn { background: var(--warn); } | |
| .usage-fill.danger { background: var(--danger); } | |
| /* ββ Forms ββ */ | |
| .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; } | |
| .form-group { display: flex; flex-direction: column; gap: 6px; } | |
| .form-group label { font-size: 12px; color: var(--muted); font-weight: 600; } | |
| input, select, textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 8px 12px; font-size: 13px; width: 100%; outline: none; transition: border-color .15s; } | |
| input:focus, select:focus { border-color: var(--accent); } | |
| select option { background: var(--bg); } | |
| .hint { font-size: 11px; color: var(--muted); margin-top: 2px; } | |
| /* ββ Buttons ββ */ | |
| .btn { padding: 8px 18px; border-radius: var(--radius); border: none; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s; } | |
| .btn-primary { background: var(--accent); color: #000; } | |
| .btn-primary:hover { opacity: .88; } | |
| .btn-secondary { background: transparent; border: 1px solid var(--border); color: var(--text); } | |
| .btn-secondary:hover { border-color: var(--accent); color: var(--accent); } | |
| .btn-danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); } | |
| .btn-danger:hover { background: rgba(255,79,107,.1); } | |
| .btn:disabled { opacity: .4; cursor: not-allowed; } | |
| /* ββ Toast ββ */ | |
| #toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 20px; font-size: 13px; display: none; z-index: 999; box-shadow: 0 8px 24px rgba(0,0,0,.4); max-width: 320px; } | |
| #toast.success { border-color: var(--accent); color: var(--accent); } | |
| #toast.error { border-color: var(--danger); color: var(--danger); } | |
| /* ββ Modal ββ */ | |
| .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; display: none; align-items: center; justify-content: center; } | |
| .modal-overlay.open { display: flex; } | |
| .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; width: 480px; max-width: 95vw; } | |
| .modal h3 { font-size: 16px; margin-bottom: 20px; } | |
| .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; } | |
| /* ββ Misc ββ */ | |
| .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } | |
| .section-title { font-size: 18px; font-weight: 700; } | |
| .empty-state { text-align: center; color: var(--muted); padding: 48px 0; font-size: 13px; } | |
| .tx-source { padding: 2px 6px; border-radius: 4px; font-size: 11px; background: rgba(91,110,245,.2); color: var(--accent2); } | |
| code { font-family: monospace; background: rgba(255,255,255,.07); padding: 1px 6px; border-radius: 4px; font-size: 12px; } | |
| .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.1); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; vertical-align: middle; margin-right: 6px; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ββ Login ββ --> | |
| <div id="login-screen"> | |
| <div class="login-card"> | |
| <h1>π¨ HD Remover</h1> | |
| <p>Admin Dashboard β Enter password to continue</p> | |
| <div class="form-group"> | |
| <input type="password" id="pw-input" placeholder="Admin password" autofocus> | |
| </div> | |
| <div class="form-group" style="margin-top:10px"> | |
| <input type="text" id="otp-input" placeholder="2FA code (optional)" maxlength="6" style="letter-spacing:4px;text-align:center"> | |
| </div> | |
| <button class="btn btn-primary" style="width:100%;margin-top:14px" onclick="doLogin()">Login</button> | |
| <div class="login-err" id="login-err"></div> | |
| </div> | |
| </div> | |
| <!-- ββ App ββ --> | |
| <div id="app"> | |
| <div class="topbar"> | |
| <span class="logo">π¨ HD Remover</span> | |
| <span class="status-dot"></span> | |
| <span class="health-badge" id="health-txt">checking...</span> | |
| <div class="spacer"></div> | |
| <button class="btn btn-secondary" style="font-size:12px;padding:5px 12px" onclick="logout()">Logout</button> | |
| </div> | |
| <div class="main"> | |
| <div class="sidebar"> | |
| <div class="nav-item active" onclick="showPage('stats')"><span class="nav-icon">π</span> Stats</div> | |
| <div class="nav-item" onclick="showPage('customers')"><span class="nav-icon">π₯</span> Customers</div> | |
| <div class="nav-item" onclick="showPage('add')"><span class="nav-icon">β</span> Add Customer</div> | |
| <div class="nav-item" onclick="showPage('transactions')"><span class="nav-icon">π³</span> Transactions</div> | |
| <div class="nav-item" onclick="showPage('plans')"><span class="nav-icon">π§©</span> Plans</div> | |
| <div class="nav-item" onclick="showPage('paylog')"><span class="nav-icon">π§Ύ</span> Payments Log</div> | |
| </div> | |
| <div class="content"> | |
| <!-- ββ Stats ββ --> | |
| <div class="page active" id="page-stats"> | |
| <div class="section-header"> | |
| <span class="section-title">Dashboard Overview</span> | |
| <button class="btn btn-secondary" onclick="loadStats()">π Refresh</button> | |
| </div> | |
| <div class="stat-grid" id="stat-grid"> | |
| <div class="stat-card"><div class="stat-val" id="s-total">β</div><div class="stat-label">Total Customers</div></div> | |
| <div class="stat-card"><div class="stat-val" id="s-calls">β</div><div class="stat-label">Calls Today</div></div> | |
| <div class="stat-card"><div class="stat-val" id="s-rev">β</div><div class="stat-label">Est. Monthly Rev</div></div> | |
| <div class="stat-card"><div class="stat-val" id="s-tx">β</div><div class="stat-label">Total Transactions</div></div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title">Plan Breakdown</div> | |
| <div id="plan-breakdown" style="display:flex;gap:24px;flex-wrap:wrap"></div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-title">Models Ready</div> | |
| <div id="models-ready" style="display:flex;gap:8px;flex-wrap:wrap"></div> | |
| </div> | |
| </div> | |
| <!-- ββ Customers ββ --> | |
| <div class="page" id="page-customers"> | |
| <div class="section-header"> | |
| <span class="section-title">Customers</span> | |
| <div style="display:flex;gap:8px"> | |
| <input type="text" id="search-input" placeholder="Search email / keyβ¦" style="width:220px" oninput="filterCustomers()"> | |
| <button class="btn btn-secondary" onclick="loadCustomers()">π Refresh</button> | |
| </div> | |
| </div> | |
| <div class="card" style="padding:0"> | |
| <div class="tbl-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Email</th> | |
| <th>API Key</th> | |
| <th>Plan</th> | |
| <th>Usage Today</th> | |
| <th>Expires</th> | |
| <th>Source</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody id="customers-tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Add Customer ββ --> | |
| <div class="page" id="page-add"> | |
| <div class="section-header"> | |
| <span class="section-title">Add New Customer</span> | |
| </div> | |
| <div class="card"> | |
| <div class="form-grid"> | |
| <div class="form-group"> | |
| <label>Email *</label> | |
| <input type="email" id="add-email" placeholder="customer@gmail.com"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Plan</label> | |
| <select id="add-plan"></select> | |
| </div> | |
| <div class="form-group"> | |
| <label>Custom Daily Limit</label> | |
| <input type="number" id="add-limit" placeholder="leave empty = plan default"> | |
| <div class="hint">Overrides plan's default daily limit</div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Expires In (days)</label> | |
| <input type="number" id="add-expiry" placeholder="leave empty = plan default"> | |
| <div class="hint">Overrides the selected plan's default expiry</div> | |
| </div> | |
| <div class="form-group" style="grid-column: 1/-1"> | |
| <label>Webhook URL (optional)</label> | |
| <input type="url" id="add-webhook" placeholder="https://your-site.com/webhook β fires at 80% usage"> | |
| </div> | |
| </div> | |
| <div style="margin-top:20px"> | |
| <button class="btn btn-primary" onclick="addCustomer()" id="add-btn">β¨ Generate API Key</button> | |
| </div> | |
| </div> | |
| <div class="card" id="add-result-card" style="display:none"> | |
| <div class="card-title">β Key Generated</div> | |
| <div id="add-result-body"></div> | |
| </div> | |
| </div> | |
| <!-- ββ Transactions ββ --> | |
| <div class="page" id="page-transactions"> | |
| <div class="section-header"> | |
| <span class="section-title">Payment Transactions</span> | |
| <button class="btn btn-secondary" onclick="loadTransactions()">π Refresh</button> | |
| </div> | |
| <div class="card" style="padding:0"> | |
| <div class="tbl-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Date</th> | |
| <th>Provider</th> | |
| <th>Email</th> | |
| <th>Plan</th> | |
| <th>Amount</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tx-tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Plans ββ --> | |
| <div class="page" id="page-plans"> | |
| <div class="section-header"> | |
| <span class="section-title">Plans</span> | |
| <div style="display:flex;gap:8px"> | |
| <button class="btn btn-secondary" onclick="openPlanEdit('')">β New Plan</button> | |
| <button class="btn btn-secondary" onclick="loadPlans()">π Refresh</button> | |
| </div> | |
| </div> | |
| <div class="hint" style="margin-bottom:16px"> | |
| Changes here apply to future signups/upgrades immediately. To also push | |
| new limits onto customers already on a plan, check "Apply to existing | |
| customers" when saving. | |
| </div> | |
| <div class="card" style="padding:0"> | |
| <div class="tbl-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Plan ID</th> | |
| <th>Daily Limit</th> | |
| <th>Per-Minute</th> | |
| <th>Price</th> | |
| <th>Default Expiry</th> | |
| <th>Models</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody id="plans-tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="section-header" style="margin-top:32px"> | |
| <span class="section-title">Whop Product β Plan Mapping</span> | |
| </div> | |
| <div class="hint" style="margin-bottom:16px"> | |
| Find each Product ID in your Whop dashboard under Products. Mapping | |
| a product here means a payment for it always resolves to the exact | |
| right plan, even if you change its price later. Products with no | |
| mapping fall back to a rough $-based guess (logged as a warning) β | |
| add a mapping to fix that permanently. | |
| </div> | |
| <div class="card"> | |
| <div class="form-grid"> | |
| <div class="form-group"> | |
| <label>Whop Product ID</label> | |
| <input type="text" id="whop-map-product-id" placeholder="prod_abc123"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Maps to Plan</label> | |
| <select id="whop-map-plan-id"></select> | |
| </div> | |
| </div> | |
| <div style="margin-top:16px"> | |
| <button class="btn btn-primary" onclick="addWhopMapping()">Add / Update Mapping</button> | |
| </div> | |
| </div> | |
| <div class="card" style="padding:0"> | |
| <div class="tbl-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Whop Product ID</th> | |
| <th>Maps to Plan</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody id="whop-map-tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Payments Log ββ --> | |
| <div class="page" id="page-paylog"> | |
| <div class="section-header"> | |
| <span class="section-title">Payments Log</span> | |
| <button class="btn btn-secondary" onclick="loadPayLog()">π Refresh</button> | |
| </div> | |
| <div class="hint" style="margin-bottom:16px"> | |
| Every payment webhook received β Whop Test Mode and live, successful | |
| and failed. Test Mode events are logged here but never create or | |
| change a real customer account, so it's safe to click "Test Payment" | |
| in Whop's dashboard any time. Signature-verification failures show | |
| up here too, which is how you'd notice someone probing the webhook | |
| endpoint with forged requests. | |
| </div> | |
| <div class="card" style="padding:0"> | |
| <div class="tbl-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Date</th> | |
| <th>Mode</th> | |
| <th>Status</th> | |
| <th>Email</th> | |
| <th>Plan</th> | |
| <th>Amount</th> | |
| <th>Detail</th> | |
| </tr> | |
| </thead> | |
| <tbody id="paylog-tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div><!-- content --> | |
| </div><!-- main --> | |
| </div><!-- app --> | |
| <!-- ββ Plan Edit Modal ββ --> | |
| <div class="modal-overlay" id="plan-modal"> | |
| <div class="modal"> | |
| <h3 id="plan-modal-title">βοΈ Edit Plan</h3> | |
| <div class="form-grid"> | |
| <div class="form-group"> | |
| <label>Plan ID</label> | |
| <input type="text" id="plan-id" placeholder="e.g. business"> | |
| <div class="hint" id="plan-id-hint">Lowercase letters/numbers/underscore only. Can't be changed after creation.</div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Daily Limit</label> | |
| <input type="number" id="plan-daily" placeholder="e.g. 500"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Per-Minute Limit</label> | |
| <input type="number" id="plan-per-minute" placeholder="e.g. 30"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Price (display only)</label> | |
| <input type="text" id="plan-price" placeholder="e.g. $29/mo"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Default Expiry (days)</label> | |
| <input type="number" id="plan-expiry" placeholder="leave empty = never expires"> | |
| </div> | |
| <div class="form-group" style="grid-column:1/-1"> | |
| <label>Models (comma-separated)</label> | |
| <input type="text" id="plan-models" placeholder="fast,quality,best,hair"> | |
| </div> | |
| <div class="form-group" style="grid-column:1/-1"> | |
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> | |
| <input type="checkbox" id="plan-reapply" style="width:auto"> | |
| Apply new limits to existing customers on this plan right now | |
| </label> | |
| </div> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-secondary" onclick="closePlanModal()">Cancel</button> | |
| <button class="btn btn-primary" onclick="savePlan()">Save Plan</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Edit Modal ββ --> | |
| <div class="modal-overlay" id="edit-modal"> | |
| <div class="modal"> | |
| <h3>βοΈ Edit Customer</h3> | |
| <div class="form-grid"> | |
| <div class="form-group"> | |
| <label>Email</label> | |
| <input type="email" id="edit-email"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Plan</label> | |
| <select id="edit-plan"></select> | |
| </div> | |
| <div class="form-group"> | |
| <label>Extend Expiry (days from now)</label> | |
| <input type="number" id="edit-expiry" placeholder="leave empty = no change"> | |
| </div> | |
| <div class="form-group" style="grid-column:1/-1"> | |
| <label>Webhook URL</label> | |
| <input type="url" id="edit-webhook" placeholder="https://..."> | |
| </div> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-secondary" onclick="closeModal()">Cancel</button> | |
| <button class="btn btn-primary" onclick="saveEdit()">Save Changes</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Toast ββ --> | |
| <div id="toast"></div> | |
| <script> | |
| const API = ''; // same origin | |
| let _pass = ''; | |
| let _customers = []; | |
| let _editKey = ''; | |
| let _plans = {}; | |
| let _editingPlanId = ''; | |
| // ββ Login ββ | |
| document.getElementById('pw-input').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') doLogin(); | |
| }); | |
| async function doLogin() { | |
| const pw = document.getElementById('pw-input').value.trim(); | |
| const otp = document.getElementById('otp-input').value.trim(); | |
| if (!pw) return; | |
| // Verify by hitting /admin/customers | |
| const r = await fetch(`${API}/admin/customers`, { | |
| headers: { 'X-Admin-Password': pw } | |
| }); | |
| if (r.ok) { | |
| _pass = pw; | |
| sessionStorage.setItem('hdadmin', pw); | |
| sessionStorage.setItem('hdotp', otp); | |
| document.getElementById('login-screen').style.display = 'none'; | |
| document.getElementById('app').style.display = 'block'; | |
| init(); | |
| } else { | |
| document.getElementById('login-err').textContent = 'β Wrong password'; | |
| } | |
| } | |
| function logout() { | |
| sessionStorage.removeItem('hdadmin'); | |
| location.reload(); | |
| } | |
| // Auto-restore session | |
| const saved = sessionStorage.getItem('hdadmin'); | |
| if (saved) { | |
| _pass = saved; | |
| document.getElementById('login-screen').style.display = 'none'; | |
| document.getElementById('app').style.display = 'block'; | |
| setTimeout(init, 0); | |
| } | |
| // ββ Init ββ | |
| function init() { | |
| loadHealth(); | |
| loadStats(); | |
| loadCustomers(); | |
| loadPlans(); | |
| setInterval(loadHealth, 30000); | |
| } | |
| // ββ Navigation ββ | |
| function showPage(name) { | |
| document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| document.getElementById(`page-${name}`).classList.add('active'); | |
| event.currentTarget.classList.add('active'); | |
| if (name === 'transactions') loadTransactions(); | |
| if (name === 'stats') loadStats(); | |
| if (name === 'plans') loadPlans(); | |
| if (name === 'paylog') loadPayLog(); | |
| } | |
| // ββ Health ββ | |
| async function loadHealth() { | |
| try { | |
| const r = await fetch(`${API}/health`); | |
| const d = await r.json(); | |
| const el = document.getElementById('health-txt'); | |
| el.textContent = `${d.users_loaded} users Β· models: ${(d.models_ready||[]).join(', ')||'loading...'}`; | |
| } catch(e) { | |
| document.getElementById('health-txt').textContent = 'offline?'; | |
| } | |
| } | |
| // ββ Stats ββ | |
| async function loadStats() { | |
| const r = await apiFetch('/admin/stats'); | |
| if (!r) return; | |
| document.getElementById('s-total').textContent = r.total_customers; | |
| document.getElementById('s-calls').textContent = r.total_calls_today; | |
| document.getElementById('s-rev').textContent = r.est_monthly_rev; | |
| document.getElementById('s-tx').textContent = r.total_transactions; | |
| // Plan breakdown | |
| const pc = r.plan_counts || {}; | |
| const pb = document.getElementById('plan-breakdown'); | |
| pb.innerHTML = Object.entries(pc).map(([plan, cnt]) => | |
| `<div class="stat-card" style="min-width:120px"> | |
| <div class="stat-val" style="font-size:20px">${cnt}</div> | |
| <div class="stat-label"><span class="plan-badge plan-${plan}">${plan}</span></div> | |
| </div>` | |
| ).join('') || '<span style="color:var(--muted)">No customers yet</span>'; | |
| // Models | |
| const mr = r.models_ready || []; | |
| const modelNames = { u2net:'U2-Net', birefnet:'BiRefNet-lite', ben2:'BEN2', rmbg:'RMBG-2.0' }; | |
| document.getElementById('models-ready').innerHTML = ['u2net','birefnet','ben2','rmbg'].map(k => | |
| `<span style="padding:4px 12px;border-radius:20px;font-size:12px;border:1px solid var(--border);color:${mr.includes(k)?'var(--accent)':'var(--muted)'};border-color:${mr.includes(k)?'var(--accent)':'var(--border)'}"> | |
| ${mr.includes(k)?'β ':'β³'} ${modelNames[k]||k} | |
| </span>` | |
| ).join(''); | |
| } | |
| // ββ Customers ββ | |
| async function loadCustomers() { | |
| const r = await apiFetch('/admin/customers'); | |
| if (!r) return; | |
| _customers = r.customers || []; | |
| renderCustomers(_customers); | |
| } | |
| function filterCustomers() { | |
| const q = document.getElementById('search-input').value.toLowerCase(); | |
| renderCustomers(q ? _customers.filter(c => | |
| (c.email||'').toLowerCase().includes(q) || (c.api_key||'').toLowerCase().includes(q) | |
| ) : _customers); | |
| } | |
| function renderCustomers(list) { | |
| const tb = document.getElementById('customers-tbody'); | |
| if (!list.length) { | |
| tb.innerHTML = '<tr><td colspan="7" class="empty-state">No customers found</td></tr>'; | |
| return; | |
| } | |
| tb.innerHTML = list.map(c => { | |
| const pct = c.daily_limit > 0 ? Math.min(100, Math.round((c.calls_today / c.daily_limit) * 100)) : 0; | |
| const fillClass = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : ''; | |
| return `<tr> | |
| <td>${c.email || '<em style="color:var(--muted)">β</em>'}</td> | |
| <td> | |
| <span class="key-mono" title="Click to copy" onclick="copyKey('${c.api_key}')">${c.api_key}</span> | |
| </td> | |
| <td><span class="plan-badge plan-${c.plan}">${c.plan}</span></td> | |
| <td> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| <div class="usage-bar"><div class="usage-fill ${fillClass}" style="width:${pct}%"></div></div> | |
| <span style="color:var(--muted);font-size:12px">${c.calls_today}/${c.daily_limit}</span> | |
| </div> | |
| </td> | |
| <td style="color:${c.expires_str==='Never'?'var(--muted)':'var(--text)'};font-size:12px">${c.expires_str}</td> | |
| <td><span class="tx-source">${c.source||'manual'}</span></td> | |
| <td> | |
| <div style="display:flex;gap:6px"> | |
| <button class="action-btn" onclick="openEdit('${c.api_key}','${c.email}','${c.plan}','${c.webhook_url||''}')">βοΈ</button> | |
| <button class="action-btn" onclick="resetUsage('${c.api_key}','${c.email}')">π</button> | |
| <button class="action-btn danger" onclick="deleteCustomer('${c.api_key}','${c.email}')">ποΈ</button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| function copyKey(key) { | |
| navigator.clipboard.writeText(key).then(() => toast('Copied!', 'success')); | |
| } | |
| async function resetUsage(key, email) { | |
| if (!confirm(`Reset daily usage for ${email}?`)) return; | |
| const r = await apiFetch(`/admin/customers/${key}/reset-usage`, 'POST', {}); | |
| if (r?.success) { toast('Usage reset β ', 'success'); loadCustomers(); } | |
| } | |
| async function deleteCustomer(key, email) { | |
| if (!confirm(`Delete customer ${email}?\n\nThis cannot be undone.`)) return; | |
| const r = await apiFetchRaw(`/admin/customers/${key}`, 'DELETE'); | |
| if (r.ok) { toast(`Deleted ${email}`, 'success'); loadCustomers(); loadStats(); } | |
| else { const d = await r.json(); toast(d.error || 'Delete failed', 'error'); } | |
| } | |
| // ββ Edit Modal ββ | |
| function openEdit(key, email, plan, webhook) { | |
| _editKey = key; | |
| document.getElementById('edit-email').value = email || ''; | |
| document.getElementById('edit-plan').value = plan || 'starter'; | |
| document.getElementById('edit-webhook').value = webhook || ''; | |
| document.getElementById('edit-expiry').value = ''; | |
| document.getElementById('edit-modal').classList.add('open'); | |
| } | |
| function closeModal() { | |
| document.getElementById('edit-modal').classList.remove('open'); | |
| _editKey = ''; | |
| } | |
| async function saveEdit() { | |
| const body = { | |
| email: document.getElementById('edit-email').value.trim(), | |
| plan: document.getElementById('edit-plan').value, | |
| webhook_url: document.getElementById('edit-webhook').value.trim(), | |
| }; | |
| const exp = document.getElementById('edit-expiry').value.trim(); | |
| if (exp) body.expiry_days = parseInt(exp); | |
| const r = await apiFetch(`/admin/customers/${_editKey}/upgrade`, 'POST', body); | |
| if (r?.success) { | |
| toast('Saved β ', 'success'); | |
| closeModal(); | |
| loadCustomers(); | |
| loadStats(); | |
| } | |
| } | |
| // ββ Add Customer ββ | |
| async function addCustomer() { | |
| const email = document.getElementById('add-email').value.trim(); | |
| const plan = document.getElementById('add-plan').value; | |
| const limit = document.getElementById('add-limit').value.trim(); | |
| const expiry = document.getElementById('add-expiry').value.trim(); | |
| const webhook = document.getElementById('add-webhook').value.trim(); | |
| if (!email || !email.includes('@')) { | |
| toast('Valid email required', 'error'); return; | |
| } | |
| const btn = document.getElementById('add-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span>Generating...'; | |
| const body = { email, plan }; | |
| if (limit) body.custom_limit = parseInt(limit); | |
| if (expiry) body.expiry_days = parseInt(expiry); | |
| if (webhook) body.webhook_url = webhook; | |
| const r = await apiFetch('/admin/customers', 'POST', body); | |
| btn.disabled = false; | |
| btn.textContent = 'β¨ Generate API Key'; | |
| if (!r?.success) { toast(r?.error || 'Failed', 'error'); return; } | |
| const expStr = r.expires_at | |
| ? new Date(r.expires_at * 1000).toISOString().slice(0, 10) | |
| : 'Never'; | |
| document.getElementById('add-result-card').style.display = 'block'; | |
| document.getElementById('add-result-body').innerHTML = ` | |
| <div style="display:flex;flex-direction:column;gap:10px"> | |
| <div><span style="color:var(--muted);font-size:12px">Email:</span> ${r.email}</div> | |
| <div><span style="color:var(--muted);font-size:12px">Plan:</span> <span class="plan-badge plan-${r.plan}">${r.plan}</span></div> | |
| <div><span style="color:var(--muted);font-size:12px">Expires:</span> ${expStr}</div> | |
| <div style="background:rgba(0,229,160,.08);border:1px solid var(--accent);border-radius:6px;padding:12px;font-family:monospace;font-size:13px;word-break:break-all;cursor:pointer" onclick="copyKey('${r.api_key}')" title="Click to copy"> | |
| π ${r.api_key} | |
| </div> | |
| <div style="color:var(--muted);font-size:12px">π Click key to copy. Send to customer.</div> | |
| </div> | |
| `; | |
| toast('Key created β ', 'success'); | |
| loadCustomers(); | |
| loadStats(); | |
| } | |
| // ββ Transactions ββ | |
| async function loadTransactions() { | |
| const r = await apiFetch('/admin/transactions'); | |
| if (!r) return; | |
| const txs = (r.transactions || []).slice().reverse(); // newest first | |
| const tb = document.getElementById('tx-tbody'); | |
| if (!txs.length) { | |
| tb.innerHTML = '<tr><td colspan="5" class="empty-state">No transactions yet</td></tr>'; | |
| return; | |
| } | |
| tb.innerHTML = txs.map(tx => { | |
| const date = tx.received_at ? tx.received_at.slice(0,16).replace('T',' ') : 'β'; | |
| return `<tr> | |
| <td style="font-size:12px;color:var(--muted)">${date}</td> | |
| <td><span class="tx-source">${tx.provider||'β'}</span></td> | |
| <td>${tx.email||'β'}</td> | |
| <td><span class="plan-badge plan-${tx.plan||'free'}">${tx.plan||'β'}</span></td> | |
| <td>${tx.amount ? `${tx.amount} ${tx.currency||''}` : 'β'}</td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| // ββ Plans ββ | |
| async function loadPlans() { | |
| const r = await apiFetch('/admin/plans'); | |
| if (!r) return; | |
| _plans = r.plans || {}; | |
| renderPlansTable(); | |
| populatePlanDropdowns(); | |
| loadWhopMap(); | |
| } | |
| function renderPlansTable() { | |
| const tb = document.getElementById('plans-tbody'); | |
| if (!tb) return; | |
| const ids = Object.keys(_plans); | |
| if (!ids.length) { | |
| tb.innerHTML = '<tr><td colspan="7" class="empty-state">No plans configured</td></tr>'; | |
| return; | |
| } | |
| tb.innerHTML = ids.map(id => { | |
| const p = _plans[id] || {}; | |
| const expiry = (p.default_expiry_days === null || p.default_expiry_days === undefined) | |
| ? 'Never' : `${p.default_expiry_days}d`; | |
| const models = (p.models || []).join(', ') || 'β'; | |
| return `<tr> | |
| <td><span class="plan-badge plan-${id}">${id}</span></td> | |
| <td>${p.daily ?? 'β'}</td> | |
| <td>${p.per_minute ?? 'β'}</td> | |
| <td>${p.price || 'β'}</td> | |
| <td>${expiry}</td> | |
| <td style="font-size:12px;color:var(--muted)">${models}</td> | |
| <td> | |
| <div style="display:flex;gap:6px"> | |
| <button class="action-btn" onclick='openPlanEdit(${JSON.stringify(id)})'>βοΈ</button> | |
| <button class="action-btn danger" onclick="deletePlan('${id}')">ποΈ</button> | |
| </div> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| function populatePlanDropdowns() { | |
| const ids = Object.keys(_plans); | |
| const opts = ids.map(id => { | |
| const p = _plans[id] || {}; | |
| const label = `${id.charAt(0).toUpperCase()}${id.slice(1)} β ${p.daily ?? '?'}/day${p.price ? ' Β· ' + p.price : ''}`; | |
| return `<option value="${id}">${label}</option>`; | |
| }).join(''); | |
| const addSel = document.getElementById('add-plan'); | |
| const editSel = document.getElementById('edit-plan'); | |
| if (addSel) { const cur = addSel.value; addSel.innerHTML = opts; if (ids.includes(cur)) addSel.value = cur; } | |
| if (editSel) { const cur = editSel.value; editSel.innerHTML = opts; if (ids.includes(cur)) editSel.value = cur; } | |
| } | |
| function openPlanEdit(planId) { | |
| _editingPlanId = planId || ''; | |
| const isNew = !planId; | |
| document.getElementById('plan-modal-title').textContent = isNew ? 'β New Plan' : `βοΈ Edit Plan β ${planId}`; | |
| const idInput = document.getElementById('plan-id'); | |
| idInput.value = planId || ''; | |
| idInput.disabled = !isNew; | |
| document.getElementById('plan-id-hint').style.display = isNew ? 'block' : 'none'; | |
| const p = (!isNew && _plans[planId]) || {}; | |
| document.getElementById('plan-daily').value = p.daily ?? ''; | |
| document.getElementById('plan-per-minute').value = p.per_minute ?? ''; | |
| document.getElementById('plan-price').value = p.price ?? ''; | |
| document.getElementById('plan-expiry').value = (p.default_expiry_days ?? '') === null ? '' : (p.default_expiry_days ?? ''); | |
| document.getElementById('plan-models').value = (p.models || []).join(','); | |
| document.getElementById('plan-reapply').checked = false; | |
| document.getElementById('plan-modal').classList.add('open'); | |
| } | |
| function closePlanModal() { | |
| document.getElementById('plan-modal').classList.remove('open'); | |
| _editingPlanId = ''; | |
| } | |
| async function savePlan() { | |
| const isNew = !_editingPlanId; | |
| const planId = isNew | |
| ? document.getElementById('plan-id').value.trim().toLowerCase() | |
| : _editingPlanId; | |
| if (!planId || !/^[a-z0-9_]+$/.test(planId)) { | |
| toast('Plan ID must be lowercase letters/numbers/underscore', 'error'); | |
| return; | |
| } | |
| const daily = document.getElementById('plan-daily').value.trim(); | |
| const perMin = document.getElementById('plan-per-minute').value.trim(); | |
| const price = document.getElementById('plan-price').value.trim(); | |
| const expiry = document.getElementById('plan-expiry').value.trim(); | |
| const modelsStr = document.getElementById('plan-models').value.trim(); | |
| const reapply = document.getElementById('plan-reapply').checked; | |
| const body = {}; | |
| if (daily !== '') body.daily = parseInt(daily); | |
| if (perMin !== '') body.per_minute = parseInt(perMin); | |
| if (price !== '') body.price = price; | |
| body.default_expiry_days = expiry === '' ? null : parseInt(expiry); | |
| if (modelsStr !== '') body.models = modelsStr.split(',').map(m => m.trim()).filter(Boolean); | |
| if (reapply) body.reapply_existing = true; | |
| const r = await apiFetch(`/admin/plans/${planId}`, 'POST', body); | |
| if (r?.success) { | |
| toast(reapply && r.reapplied_to | |
| ? `Plan saved β applied to ${r.reapplied_to} existing customer(s) β ` | |
| : 'Plan saved β ', 'success'); | |
| closePlanModal(); | |
| loadPlans(); | |
| loadStats(); | |
| } else { | |
| toast(r?.error || 'Failed to save plan', 'error'); | |
| } | |
| } | |
| async function deletePlan(planId) { | |
| if (!confirm(`Delete plan "${planId}"?\n\nOnly works if no customers are currently on it.`)) return; | |
| const r = await apiFetchRaw(`/admin/plans/${planId}`, 'DELETE'); | |
| if (r.ok) { | |
| toast(`Deleted plan ${planId}`, 'success'); | |
| loadPlans(); | |
| } else { | |
| const d = await r.json(); | |
| toast(d.error || 'Delete failed', 'error'); | |
| } | |
| } | |
| // ββ Whop Product -> Plan Mapping ββ | |
| let _whopMap = {}; | |
| async function loadWhopMap() { | |
| const r = await apiFetch('/admin/whop-plan-map'); | |
| if (!r) return; | |
| _whopMap = r.map || {}; | |
| renderWhopMapTable(); | |
| populateWhopMapPlanSelect(); | |
| } | |
| function renderWhopMapTable() { | |
| const tb = document.getElementById('whop-map-tbody'); | |
| if (!tb) return; | |
| const ids = Object.keys(_whopMap); | |
| if (!ids.length) { | |
| tb.innerHTML = '<tr><td colspan="3" class="empty-state">No mappings yet β payments fall back to a rough amount-based guess</td></tr>'; | |
| return; | |
| } | |
| tb.innerHTML = ids.map(pid => `<tr> | |
| <td><code>${pid}</code></td> | |
| <td><span class="plan-badge plan-${_whopMap[pid]}">${_whopMap[pid]}</span></td> | |
| <td><button class="action-btn danger" onclick="deleteWhopMapping('${pid}')">ποΈ</button></td> | |
| </tr>`).join(''); | |
| } | |
| function populateWhopMapPlanSelect() { | |
| const sel = document.getElementById('whop-map-plan-id'); | |
| if (!sel) return; | |
| const ids = Object.keys(_plans); | |
| sel.innerHTML = ids.map(id => `<option value="${id}">${id}</option>`).join(''); | |
| } | |
| async function addWhopMapping() { | |
| const productId = document.getElementById('whop-map-product-id').value.trim(); | |
| const planId = document.getElementById('whop-map-plan-id').value; | |
| if (!productId) { toast('Whop Product ID required', 'error'); return; } | |
| const r = await apiFetch('/admin/whop-plan-map', 'POST', { whop_product_id: productId, plan_id: planId }); | |
| if (r?.success) { | |
| toast('Mapping saved β ', 'success'); | |
| document.getElementById('whop-map-product-id').value = ''; | |
| loadWhopMap(); | |
| } else { | |
| toast(r?.error || 'Failed to save mapping', 'error'); | |
| } | |
| } | |
| async function deleteWhopMapping(productId) { | |
| if (!confirm(`Remove mapping for ${productId}?`)) return; | |
| const r = await apiFetchRaw(`/admin/whop-plan-map/${productId}`, 'DELETE'); | |
| if (r.ok) { toast('Mapping removed', 'success'); loadWhopMap(); } | |
| else { const d = await r.json(); toast(d.error || 'Failed', 'error'); } | |
| } | |
| // ββ Payments Log ββ | |
| async function loadPayLog() { | |
| const r = await apiFetch('/admin/webhook-log'); | |
| if (!r) return; | |
| const log = r.log || []; | |
| const tb = document.getElementById('paylog-tbody'); | |
| if (!log.length) { | |
| tb.innerHTML = '<tr><td colspan="7" class="empty-state">No payment events logged yet</td></tr>'; | |
| return; | |
| } | |
| tb.innerHTML = log.map(e => { | |
| const date = e.received_at ? e.received_at.slice(0, 16).replace('T', ' ') : 'β'; | |
| const modeColor = e.mode === 'live' ? 'var(--accent)' : e.mode === 'test' ? 'var(--warn)' : 'var(--muted)'; | |
| const statusColor = e.status === 'ok' ? 'var(--accent)' : e.status === 'error' ? 'var(--danger)' : 'var(--muted)'; | |
| return `<tr> | |
| <td style="font-size:12px;color:var(--muted)">${date}</td> | |
| <td><span style="color:${modeColor};font-weight:600;text-transform:uppercase;font-size:11px">${e.mode || 'β'}</span></td> | |
| <td><span style="color:${statusColor};font-weight:600;text-transform:uppercase;font-size:11px">${e.status || 'β'}</span></td> | |
| <td>${e.email || 'β'}</td> | |
| <td>${e.plan ? `<span class="plan-badge plan-${e.plan}">${e.plan}</span>` : 'β'}</td> | |
| <td>${e.amount ? `${e.amount} ${e.currency || ''}` : 'β'}</td> | |
| <td style="font-size:12px;color:var(--muted);max-width:280px">${e.detail || 'β'}</td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| // ββ API helpers ββ | |
| async function apiFetch(path, method='GET', body=null) { | |
| try { | |
| const opts = { | |
| method, | |
| headers: { 'X-Admin-Password': _pass, 'X-Admin-OTP': sessionStorage.getItem('hdotp')||'', 'Content-Type': 'application/json' } | |
| }; | |
| if (body) opts.body = JSON.stringify(body); | |
| const r = await fetch(`${API}${path}`, opts); | |
| return await r.json(); | |
| } catch(e) { | |
| toast('Network error', 'error'); | |
| return null; | |
| } | |
| } | |
| async function apiFetchRaw(path, method='GET', body=null) { | |
| const opts = { | |
| method, | |
| headers: { 'X-Admin-Password': _pass, 'X-Admin-OTP': sessionStorage.getItem('hdotp')||'', 'Content-Type': 'application/json' } | |
| }; | |
| if (body) opts.body = JSON.stringify(body); | |
| return await fetch(`${API}${path}`, opts); | |
| } | |
| // ββ Toast ββ | |
| let _toastTimer; | |
| function toast(msg, type='success') { | |
| const el = document.getElementById('toast'); | |
| el.textContent = (type === 'success' ? 'β ' : 'β ') + msg; | |
| el.className = type; | |
| el.style.display = 'block'; | |
| clearTimeout(_toastTimer); | |
| _toastTimer = setTimeout(() => { el.style.display = 'none'; }, 3000); | |
| } | |
| // Close modal on overlay click | |
| document.getElementById('edit-modal').addEventListener('click', function(e) { | |
| if (e.target === this) closeModal(); | |
| }); | |
| document.getElementById('plan-modal').addEventListener('click', function(e) { | |
| if (e.target === this) closePlanModal(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |