added on local at 2026-07-01 13:47:56
| 1 | <!-- ===================================================================== | |
| 2 | Admin Console (company-staff only). | |
| 3 | - Platform KPI tiles across the top | |
| 4 | - User search + table; click any row to open /admin_user.cgi | |
| 5 | The sidebar entry that links here is gated in Wrapper.pm; this | |
| 6 | page additionally calls Admin->require_admin so a hand-typed URL | |
| 7 | can't reach it either. | |
| 8 | ===================================================================== --> | |
| 9 | <style> | |
| 10 | .adm-search { | |
| 11 | display: flex; gap: 8px; align-items: center; | |
| 12 | margin: 22px 0 14px; | |
| 13 | } | |
| 14 | .adm-search input { | |
| 15 | flex: 1; | |
| 16 | background: var(--col-surface-1); | |
| 17 | border: 1px solid var(--col-border); | |
| 18 | color: var(--col-text); | |
| 19 | padding: 10px 14px; | |
| 20 | border-radius: 10px; | |
| 21 | font-size: 14px; | |
| 22 | font-family: inherit; | |
| 23 | } | |
| 24 | .adm-search input:focus { | |
| 25 | outline: none; border-color: var(--col-accent); | |
| 26 | } | |
| 27 | .adm-search .summary { | |
| 28 | font-size: 11px; color: var(--col-text-3); | |
| 29 | letter-spacing: 1.5px; text-transform: uppercase; | |
| 30 | margin-left: 8px; | |
| 31 | } | |
| 32 | ||
| 33 | .adm-utable { | |
| 34 | width: 100%; | |
| 35 | border-collapse: separate; border-spacing: 0; | |
| 36 | background: var(--col-surface-1); | |
| 37 | border: 1px solid var(--col-border); | |
| 38 | border-radius: 12px; | |
| 39 | overflow: hidden; | |
| 40 | } | |
| 41 | .adm-utable thead th { | |
| 42 | background: var(--col-surface-2); | |
| 43 | padding: 11px 14px; | |
| 44 | text-align: left; | |
| 45 | font-size: 10px; | |
| 46 | letter-spacing: 1.5px; | |
| 47 | text-transform: uppercase; | |
| 48 | color: var(--col-text-3); | |
| 49 | font-weight: 700; | |
| 50 | border-bottom: 1px solid var(--col-border); | |
| 51 | } | |
| 52 | .adm-utable tbody td { | |
| 53 | padding: 12px 14px; | |
| 54 | border-bottom: 1px solid var(--col-border); | |
| 55 | font-size: 13px; color: var(--col-text-2); | |
| 56 | vertical-align: middle; | |
| 57 | } | |
| 58 | .adm-utable tbody tr:last-child td { border-bottom: none; } | |
| 59 | .adm-utable tbody tr { | |
| 60 | cursor: pointer; transition: background 0.12s; | |
| 61 | } | |
| 62 | .adm-utable tbody tr:hover td { background: rgba(124,58,237,0.08); } | |
| 63 | .adm-utable a { color: inherit; text-decoration: none; } | |
| 64 | ||
| 65 | .adm-uname { color: var(--col-text); font-weight: 700; } | |
| 66 | .adm-uid { color: var(--col-text-3); font-size: 11px; font-family: var(--font-mono, monospace); } | |
| 67 | .adm-pill { | |
| 68 | font-size: 9px; font-weight: 700; | |
| 69 | padding: 3px 8px; border-radius: 6px; | |
| 70 | letter-spacing: 1px; text-transform: uppercase; | |
| 71 | } | |
| 72 | .adm-pill.plan-free { background: rgba(100,116,139,0.20); color: #94a3b8; } | |
| 73 | .adm-pill.plan-starter { background: rgba(59,130,246,0.18); color: #60a5fa; } | |
| 74 | .adm-pill.plan-pro { background: rgba(124,58,237,0.18); color: #a78bfa; } | |
| 75 | .adm-pill.plan-studio { background: rgba(245,158,11,0.18); color: #fbbf24; } | |
| 76 | .adm-pill.is-admin { background: rgba(34,197,94,0.18); color: #4ade80; } | |
| 77 | .adm-pill.is-suspended { background: rgba(245,158,11,0.20); color: #fbbf24; } | |
| 78 | .adm-pill.is-closed { background: rgba(239,68,68,0.20); color: #f87171; } | |
| 79 | ||
| 80 | .adm-empty { | |
| 81 | text-align: center; | |
| 82 | padding: 60px 24px; | |
| 83 | color: var(--col-text-3); | |
| 84 | } | |
| 85 | .adm-empty .lg { font-size: 14px; color: var(--col-text-2); margin-bottom: 6px; } | |
| 86 | .adm-empty .sm { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; } | |
| 87 | </style> | |
| 88 | ||
| 89 | <div class="page-head"> | |
| 90 | <div> | |
| 91 | <span class="page-eyebrow"><span class="dot"></span> Admin · Company staff only</span> | |
| 92 | <h1 class="page-title">Operations console</h1> | |
| 93 | <p class="page-subtitle">Platform-wide view across every seller. Search for a user to drill into their account.</p> | |
| 94 | </div> | |
| 95 | <div class="page-actions"> | |
| 96 | [if:$not_tutorial_mode]<a href="/admin.cgi?tutorial=1" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Sample Data View</a>[/if] | |
| 97 | [if:$tutorial_mode]<a href="/admin.cgi" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Exit sample view</a>[/if] | |
| 98 | </div> | |
| 99 | </div> | |
| 100 | ||
| 101 | [if:$tutorial_mode] | |
| 102 | <!-- Tutorial banner + intro: example-data walkthrough for staff being | |
| 103 | onboarded into the admin console. Styles live in site.css. --> | |
| 104 | <div class="tutorial-banner"> | |
| 105 | <div class="tutorial-banner-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></div> | |
| 106 | <div class="tutorial-banner-body"> | |
| 107 | <div class="tutorial-banner-title">Tutorial · Sample data only</div> | |
| 108 | <div class="tutorial-banner-text">Every KPI tile, the search results, and the user table below are filled with example data. None of this reflects the real platform — it is here so staff being onboarded into the Admin Console can see how the page reads on a populated platform.</div> | |
| 109 | </div> | |
| 110 | <a href="/admin.cgi" class="btn btn-secondary btn-sm">Exit sample view</a> | |
| 111 | </div> | |
| 112 | ||
| 113 | <div class="tutorial-intro"> | |
| 114 | <div class="tutorial-intro-eyebrow">A walkthrough of the Operations Console</div> | |
| 115 | <h2 class="tutorial-intro-title">This is what the admin console looks like on a populated platform.</h2> | |
| 116 | <p class="tutorial-intro-text">The numbers belong to an imaginary deployment with <strong>1,247 users</strong>, <strong>1,089 active accounts</strong>, <strong>86 signups in the last 30 days</strong>, <strong>1,154 active programs</strong>, <strong>8,372 lifetime conversions</strong>, and <strong>\$42,810 in commissions paid</strong> over the past month. The user table beneath the KPI tiles shows a mix of plan tiers (Free / Starter / Pro / Studio), an admin account, and one suspended and one closed account so every row pill and status variant in the UI is visible at once.</p> | |
| 117 | <p class="tutorial-intro-text">On the live page, the KPI tiles read directly from <code>users</code>, <code>affiliate_programs</code>, and <code>affiliate_conversions</code> — counts, status filters, and a 30-day sum of commissions on paid conversions. The search box runs a free-text match against email, display name, program name and numeric user id; clicking any row navigates to <code>/admin_user.cgi</code> for that account where staff can adjust plan tier, toggle admin, suspend, close, impersonate, and audit-log. Each KPI tile in the row above also carries a <strong>click-for-detail</strong> popup explaining its data source and how to interpret movement in that number.</p> | |
| 118 | <p class="tutorial-intro-text tutorial-intro-foot">When you are ready to see real data, click <a href="/admin.cgi" class="text-brand fw-700">Exit sample view</a> to return to the live console.</p> | |
| 119 | </div> | |
| 120 | [/if] | |
| 121 | ||
| 122 | <!-- Platform KPI tiles --> | |
| 123 | <div class="stat-row"> | |
| 124 | <div class="stat" data-kpi-detail='{ | |
| 125 | "eyebrow":"Users . platform-wide total", | |
| 126 | "description":"Every user account on WebSTLs regardless of status (active, suspended, closed, pending). Active is the slice you can actually transact with; the rest is here for support and admin context.", | |
| 127 | "methodology":"<code>SELECT COUNT(*) FROM users</code> -- no filter. Active count: <code>WHERE account_status='active'</code>. New-in-30d: <code>WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)</code>. Suspended and closed are filtered separately in the search below.", | |
| 128 | "kpis":[ | |
| 129 | {"label":"All users","value":"$u_total","sub":"every status"}, | |
| 130 | {"label":"Active","value":"$u_active","sub":"can sign in & transact","color":"#4ade80"}, | |
| 131 | {"label":"New (30d)","value":"$u_new_30d","sub":"signups this month"} | |
| 132 | ], | |
| 133 | "interpretation":"User count grows monotonically -- it never shrinks unless we hard-delete. Active is the meaningful number for capacity planning. The new-in-30d rate is your growth pulse; healthy SaaS sees this number stay roughly constant or accelerate. A sudden drop usually means signup is broken (test the /signup.cgi flow) or a marketing channel went dark.", | |
| 134 | "drilldowns":[ | |
| 135 | {"label":"Search all users","href":"/admin.cgi"}, | |
| 136 | {"label":"Recent signups","href":"/admin.cgi?sort=newest"} | |
| 137 | ] | |
| 138 | }'> | |
| 139 | <div class="stat-label">Users</div> | |
| 140 | <div class="stat-value">$u_total</div> | |
| 141 | <div class="stat-meta"><span class="stat-meta-label">$u_active active · $u_new_30d new (30d)</span></div> | |
| 142 | </div> | |
| 143 | <div class="stat" data-kpi-detail='{ | |
| 144 | "eyebrow":"Active programs . affiliate programs accepting clicks", | |
| 145 | "description":"Programs with status=active across every merchant on AffSoft. Draft and paused programs are excluded.", | |
| 146 | "methodology":"<code>SELECT COUNT(*) FROM affiliate_programs WHERE status='active'</code>.", | |
| 147 | "kpis":[ | |
| 148 | {"label":"Active programs","value":"$sf_total","sub":"taking clicks right now"}, | |
| 149 | {"label":"Users","value":"$u_total","sub":"for reference"} | |
| 150 | ], | |
| 151 | "interpretation":"This should grow steadily with merchant signups. A sharp drop means merchants are pausing programs en masse -- check for a billing issue or a tracker outage." | |
| 152 | }'> | |
| 153 | <div class="stat-label">Active programs</div> | |
| 154 | <div class="stat-value">$sf_total</div> | |
| 155 | <div class="stat-meta"><span class="stat-meta-label">taking clicks right now</span></div> | |
| 156 | </div> | |
| 157 | <div class="stat" data-kpi-detail='{ | |
| 158 | "eyebrow":"Conversions . platform lifetime total", | |
| 159 | "description":"Every conversion recorded across every merchant since launch. Pending / approved / paid / rejected all counted.", | |
| 160 | "methodology":"<code>SELECT COUNT(*) FROM affiliate_conversions</code>. Lifetime, no time filter.", | |
| 161 | "kpis":[ | |
| 162 | {"label":"Lifetime conversions","value":"$m_total","sub":"all statuses"} | |
| 163 | ], | |
| 164 | "interpretation":"This is your headline platform-throughput KPI. Flatlines mean tracking is broken or attribution is misconfigured -- cross-check against affiliate_clicks count for the same window." | |
| 165 | }'> | |
| 166 | <div class="stat-label">Conversions</div> | |
| 167 | <div class="stat-value">$m_total</div> | |
| 168 | <div class="stat-meta"><span class="stat-meta-label">lifetime total</span></div> | |
| 169 | </div> | |
| 170 | <div class="stat" data-kpi-detail='{ | |
| 171 | "eyebrow":"Revenue (30d) . platform-wide gross", | |
| 172 | "description":"Sum of every paid order across every storefront on the platform in the last 30 days. This is the gross-merchandise-value (GMV) number; WebSTLs revenue is ~10% of this from platform fees.", | |
| 173 | "methodology":"<code>SUM(orders.total_amount_cents) WHERE status='paid' AND created_at >= NOW() - INTERVAL 30 DAY</code>, across all storefronts. Marketplace orders are not included -- those route through their own platforms.", | |
| 174 | "kpis":[ | |
| 175 | {"label":"Commissions paid (30d)","value":"$rev_30d","sub":"total flowed to affiliates","color":"#4ade80"} | |
| 176 | ], | |
| 177 | "interpretation":"Commissions paid is the headline throughput KPI for AffSoft. Trending up means more conversions are clearing and getting paid. Sudden drops can indicate a payout-batch failure or a fraud-review backlog -- check Fraud review and Payouts." | |
| 178 | }'> | |
| 179 | <div class="stat-label">Commissions paid (30d)</div> | |
| 180 | <div class="stat-value">$rev_30d</div> | |
| 181 | <div class="stat-meta"><span class="stat-meta-label">across approved & paid conversions</span></div> | |
| 182 | </div> | |
| 183 | </div> | |
| 184 | ||
| 185 | <!-- Search --> | |
| 186 | <form method="GET" action="/admin.cgi" class="adm-search"> | |
| 187 | <input type="text" name="q" value="$q" placeholder="Search by email, name, program, or user id..." autofocus> | |
| 188 | <button class="btn btn-primary btn-sm" type="submit">Search</button> | |
| 189 | [if:$has_query]<a class="btn btn-secondary btn-sm" href="/admin.cgi">Clear</a>[/if] | |
| 190 | <span class="summary">$result_count results</span> | |
| 191 | </form> | |
| 192 | ||
| 193 | <!-- Results --> | |
| 194 | [if:$has_results] | |
| 195 | <style> | |
| 196 | .au-table-wrap { overflow-x: auto; } | |
| 197 | table.au-table { width: 100%; border-collapse: separate; border-spacing: 0; } | |
| 198 | table.au-table th { text-align: left; font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-dim, #94a3b8); padding: 10px 12px; font-weight: 700; border-bottom: 1px solid var(--col-border, #1e293b); } | |
| 199 | table.au-table td { padding: 12px; border-bottom: 1px solid var(--col-border, #1e293b); color: var(--col-text, #e2e8f0); font-size: 13px; vertical-align: middle; white-space: nowrap; } | |
| 200 | table.au-table tr:last-child td { border-bottom: none; } | |
| 201 | table.au-table tr.au-clickable { cursor: pointer; transition: background 0.12s; } | |
| 202 | table.au-table tr.au-clickable:hover { background: rgba(255,255,255,0.04); } | |
| 203 | .au-av { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 12px; flex-shrink: 0; } | |
| 204 | .au-name-cell { display: flex; align-items: center; gap: 10px; } | |
| 205 | .au-pill { display: inline-block; padding: 3px 9px; border-radius: 5px; font-size: 11px; font-weight: 600; background: var(--col-bg-3, rgba(255,255,255,0.06)); color: var(--col-text-dim, #94a3b8); border: 1px solid var(--col-border-2, rgba(255,255,255,0.10)); } | |
| 206 | .au-pill.super { background: rgba(239,68,68,0.18); color: #f87171; border-color: rgba(239,68,68,0.35); } | |
| 207 | .au-imp-btn { background: #fbbf24; color: #1a1303; border: 1px solid #fbbf24; padding: 6px 12px; border-radius: 6px; font-weight: 700; font-size: 12px; cursor: pointer; } | |
| 208 | .au-imp-btn:hover { background: #f59e0b; border-color: #f59e0b; } | |
| 209 | </style> | |
| 210 | <div class="au-table-wrap"> | |
| 211 | <table class="au-table"> | |
| 212 | <thead> | |
| 213 | <tr> | |
| 214 | <th>Member</th> | |
| 215 | <th>Email</th> | |
| 216 | <th>Program</th> | |
| 217 | <th>Role</th> | |
| 218 | <th>Status</th> | |
| 219 | <th>Last login</th> | |
| 220 | <th></th> | |
| 221 | </tr> | |
| 222 | </thead> | |
| 223 | <tbody> | |
| 224 | [loop:@users] | |
| 225 | <tr class="au-clickable" data-href="/admin_user.cgi?u=$loop1.id"> | |
| 226 | <td><div class="au-name-cell"><div class="au-av" style="background:$loop1.avatar_color">$loop1.initials</div><div>$loop1.display_name [if:$loop1.is_admin_flag]<span class="au-pill super">ADMIN</span>[/if]</div></div></td> | |
| 227 | <td>$loop1.email</td> | |
| 228 | <td>$loop1.storefront_name</td> | |
| 229 | <td><span class="au-pill">$loop1.plan_tier</span></td> | |
| 230 | <td><span class="au-pill">$loop1.account_status</span></td> | |
| 231 | <td style="font-size:12px;color:var(--col-text-dim, #94a3b8)">$loop1.last_login_at</td> | |
| 232 | <td> | |
| 233 | [if:!$loop1.is_self] | |
| 234 | <form method="POST" action="/admin_action.cgi" style="display:inline;margin:0" onclick="event.stopPropagation()"> | |
| 235 | <input type="hidden" name="act" value="impersonate"> | |
| 236 | <input type="hidden" name="u" value="$loop1.id"> | |
| 237 | <button type="submit" class="au-imp-btn">Impersonate</button> | |
| 238 | </form> | |
| 239 | [/if] | |
| 240 | </td> | |
| 241 | </tr> | |
| 242 | [/loop] | |
| 243 | </tbody> | |
| 244 | </table> | |
| 245 | </div> | |
| 246 | <script> | |
| 247 | document.querySelectorAll('table.au-table tr.au-clickable').forEach(function(tr){ | |
| 248 | tr.addEventListener('click', function(e){ | |
| 249 | if (e.target.closest('a,button,form,input')) return; | |
| 250 | window.location.href = tr.dataset.href; | |
| 251 | }); | |
| 252 | }); | |
| 253 | </script> | |
| 254 | ||
| 255 | [/if] | |
| 256 | ||
| 257 | [if:!$has_results] | |
| 258 | <div class="adm-empty"> | |
| 259 | [if:$has_query] | |
| 260 | <div class="lg">No users match "$q".</div> | |
| 261 | <div class="sm">Try part of an email, display name, program name, or numeric user id.</div> | |
| 262 | [/if] | |
| 263 | [if:!$has_query] | |
| 264 | <div class="lg">No users yet.</div> | |
| 265 | <div class="sm">Sellers will appear here as they sign up.</div> | |
| 266 | [/if] | |
| 267 | </div> | |
| 268 | [/if] | |
| 269 | ||
| 270 | <div class="adm-foot-spacer" data-end="admin-console"> </div> |