added on WebSTLs (webstls.com) at 2026-07-01 22:26:54
| 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 storefronts</strong>, <strong>8,372 published models</strong>, and <strong>\$42,810 GMV</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>storefronts</code>, <code>models</code>, and <code>orders</code> — counts, status filters, and a 30-day revenue sum across paid orders. The search box runs a free-text match against email, display name, storefront 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":"Storefronts . creator stores live on the platform", | |
| 145 | "description":"Every storefront created across every user account. One user can have multiple storefronts (we permit that even though the UI currently exposes one per user). Subdomain reservations show up here too.", | |
| 146 | "methodology":"<code>SELECT COUNT(*) FROM storefronts</code>. Includes draft and live storefronts. Suspended user storefronts are still counted -- they exist in the DB until the account is hard-deleted.", | |
| 147 | "kpis":[ | |
| 148 | {"label":"Storefronts","value":"$sf_total","sub":"created across all users"}, | |
| 149 | {"label":"Users","value":"$u_total","sub":"for reference"} | |
| 150 | ], | |
| 151 | "interpretation":"Storefront count should track user count closely (most users have exactly one). If the gap widens, somebody is building multiple test stores -- check if a single power user is responsible. A sharp drop in this number is suspicious; storefronts should not disappear unless an account closed." | |
| 152 | }'> | |
| 153 | <div class="stat-label">Storefronts</div> | |
| 154 | <div class="stat-value">$sf_total</div> | |
| 155 | <div class="stat-meta"><span class="stat-meta-label">created across all users</span></div> | |
| 156 | </div> | |
| 157 | <div class="stat" data-kpi-detail='{ | |
| 158 | "eyebrow":"Models published . live catalog items", | |
| 159 | "description":"Total models across every creator that are NOT in trash. Includes drafts, hidden, and published. The catalog is the productive output of the platform -- this number is the closest single proxy for platform productivity.", | |
| 160 | "methodology":"<code>SELECT COUNT(*) FROM models WHERE purged_at IS NULL</code>. Excludes hard-trashed rows. Per-user breakdown is available in the search-results table at the bottom of this page (sort by storefront).", | |
| 161 | "kpis":[ | |
| 162 | {"label":"In catalog","value":"$m_total","sub":"all non-purged models"}, | |
| 163 | {"label":"Per active user","value":"average","sub":"divides by $u_active"} | |
| 164 | ], | |
| 165 | "interpretation":"Healthy growth pattern: 5-15 new models per active user per month. Far below that and creators are not engaging; far above and you may have a few power users skewing the number -- look at the per-user distribution in the search table to tell which.", | |
| 166 | "drilldowns":[ | |
| 167 | {"label":"Search users","href":"/admin.cgi"} | |
| 168 | ] | |
| 169 | }'> | |
| 170 | <div class="stat-label">Models published</div> | |
| 171 | <div class="stat-value">$m_total</div> | |
| 172 | <div class="stat-meta"><span class="stat-meta-label">live in the catalog</span></div> | |
| 173 | </div> | |
| 174 | <div class="stat" data-kpi-detail='{ | |
| 175 | "eyebrow":"Revenue (30d) . platform-wide gross", | |
| 176 | "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.", | |
| 177 | "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.", | |
| 178 | "kpis":[ | |
| 179 | {"label":"GMV (30d)","value":"$rev_30d","sub":"gross merchandise value","color":"#4ade80"}, | |
| 180 | {"label":"Platform take","value":"~10%","sub":"WebSTLs revenue share"} | |
| 181 | ], | |
| 182 | "interpretation":"GMV is the headline platform health number. Trending up = creators are succeeding, which means WebSTLs revenue grows. Trending down for two consecutive 30-day windows is a real signal -- check if it is concentrated in a few accounts (a churned power-seller) or spread across many (broad demand drop).", | |
| 183 | "drilldowns":[ | |
| 184 | {"label":"Stripe payments","href":"https://dashboard.stripe.com/payments","target":"_blank"} | |
| 185 | ] | |
| 186 | }'> | |
| 187 | <div class="stat-label">Revenue (30d)</div> | |
| 188 | <div class="stat-value">$rev_30d</div> | |
| 189 | <div class="stat-meta"><span class="stat-meta-label">paid orders, all storefronts</span></div> | |
| 190 | </div> | |
| 191 | </div> | |
| 192 | ||
| 193 | <!-- Search --> | |
| 194 | <form method="GET" action="/admin.cgi" class="adm-search"> | |
| 195 | <input type="text" name="q" value="$q" placeholder="Search by email, name, storefront, or user id..." autofocus> | |
| 196 | <button class="btn btn-primary btn-sm" type="submit">Search</button> | |
| 197 | [if:$has_query]<a class="btn btn-secondary btn-sm" href="/admin.cgi">Clear</a>[/if] | |
| 198 | <span class="summary">$result_count results</span> | |
| 199 | </form> | |
| 200 | ||
| 201 | <!-- Results --> | |
| 202 | [if:$has_results] | |
| 203 | <style> | |
| 204 | .au-table-wrap { overflow-x: auto; } | |
| 205 | table.au-table { width: 100%; border-collapse: separate; border-spacing: 0; } | |
| 206 | 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); } | |
| 207 | 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; } | |
| 208 | table.au-table tr:last-child td { border-bottom: none; } | |
| 209 | table.au-table tr.au-clickable { cursor: pointer; transition: background 0.12s; } | |
| 210 | table.au-table tr.au-clickable:hover { background: rgba(255,255,255,0.04); } | |
| 211 | .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; } | |
| 212 | .au-name-cell { display: flex; align-items: center; gap: 10px; } | |
| 213 | .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)); } | |
| 214 | .au-pill.super { background: rgba(239,68,68,0.18); color: #f87171; border-color: rgba(239,68,68,0.35); } | |
| 215 | .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; } | |
| 216 | .au-imp-btn:hover { background: #f59e0b; border-color: #f59e0b; } | |
| 217 | </style> | |
| 218 | <div class="au-table-wrap"> | |
| 219 | <table class="au-table"> | |
| 220 | <thead> | |
| 221 | <tr> | |
| 222 | <th>Member</th> | |
| 223 | <th>Email</th> | |
| 224 | <th>Storefront</th> | |
| 225 | <th>Role</th> | |
| 226 | <th>Status</th> | |
| 227 | <th>Last login</th> | |
| 228 | <th></th> | |
| 229 | </tr> | |
| 230 | </thead> | |
| 231 | <tbody> | |
| 232 | [loop:@users] | |
| 233 | <tr class="au-clickable" data-href="/admin_user.cgi?u=$loop1.id"> | |
| 234 | <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> | |
| 235 | <td>$loop1.email</td> | |
| 236 | <td>$loop1.storefront_name</td> | |
| 237 | <td><span class="au-pill">$loop1.plan_tier</span></td> | |
| 238 | <td><span class="au-pill">$loop1.account_status</span></td> | |
| 239 | <td style="font-size:12px;color:var(--col-text-dim, #94a3b8)">$loop1.last_login_at</td> | |
| 240 | <td> | |
| 241 | [if:!$loop1.is_self] | |
| 242 | <form method="POST" action="/admin_action.cgi" style="display:inline;margin:0" onclick="event.stopPropagation()"> | |
| 243 | <input type="hidden" name="act" value="impersonate"> | |
| 244 | <input type="hidden" name="u" value="$loop1.id"> | |
| 245 | <button type="submit" class="au-imp-btn">Impersonate</button> | |
| 246 | </form> | |
| 247 | [/if] | |
| 248 | </td> | |
| 249 | </tr> | |
| 250 | [/loop] | |
| 251 | </tbody> | |
| 252 | </table> | |
| 253 | </div> | |
| 254 | <script> | |
| 255 | document.querySelectorAll('table.au-table tr.au-clickable').forEach(function(tr){ | |
| 256 | tr.addEventListener('click', function(e){ | |
| 257 | if (e.target.closest('a,button,form,input')) return; | |
| 258 | window.location.href = tr.dataset.href; | |
| 259 | }); | |
| 260 | }); | |
| 261 | </script> | |
| 262 | ||
| 263 | [/if] | |
| 264 | ||
| 265 | [if:!$has_results] | |
| 266 | <div class="adm-empty"> | |
| 267 | [if:$has_query] | |
| 268 | <div class="lg">No users match "$q".</div> | |
| 269 | <div class="sm">Try part of an email, display name, storefront name, or numeric user id.</div> | |
| 270 | [/if] | |
| 271 | [if:!$has_query] | |
| 272 | <div class="lg">No users yet.</div> | |
| 273 | <div class="sm">Sellers will appear here as they sign up.</div> | |
| 274 | [/if] | |
| 275 | </div> | |
| 276 | [/if] | |
| 277 | ||
| 278 | <div class="adm-foot-spacer" data-end="admin-console"> </div> |