hdremover's picture
Update static/index.html
5198a6b verified
Raw
History Blame Contribute Delete
42.3 kB
<!DOCTYPE html>
<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>