Gym — Members Admin
A high-energy members admin console for a performance gym. A KPI header tracks active members, new signups, churn and MRR with mini sparklines, above a searchable, filterable and fully sortable members table with status pills, tier labels and lifetime value. Bulk-select rows to freeze, message or cancel in batch, page through results, and click any row to slide open a detail drawer with the member profile, plan and contact info. All vanilla JS.
MCP
Código
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-2: 0 10px 30px rgba(0, 0, 0, 0.45);
--sh-3: 0 24px 60px rgba(0, 0, 0, 0.55);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
background:
radial-gradient(900px 500px at 88% -8%, rgba(198, 255, 58, 0.07), transparent 60%),
radial-gradient(700px 460px at -6% 0%, rgba(255, 106, 43, 0.06), transparent 55%),
var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font-family: inherit; }
:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 28px 24px 64px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: linear-gradient(135deg, var(--neon), var(--neon-d));
color: #0d0f12;
font-weight: 900;
font-size: 17px;
letter-spacing: 0.5px;
box-shadow: 0 6px 18px rgba(198, 255, 58, 0.28);
}
.brand-eyebrow {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.brand-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.topbar-actions { display: flex; gap: 10px; }
/* ---------- Buttons ---------- */
.btn {
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font-weight: 600;
font-size: 14px;
padding: 10px 16px;
border-radius: var(--r-md);
cursor: pointer;
transition: transform 0.06s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.btn:hover { background: var(--elevated); border-color: var(--line-2); }
.btn:active { transform: translateY(1px); }
.btn-sm { padding: 7px 12px; font-size: 13px; border-radius: var(--r-sm); }
.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink-2); }
.btn-ghost:hover { color: var(--ink); border-color: var(--line-2); }
.btn-neon {
background: linear-gradient(135deg, var(--neon), var(--neon-d));
color: #0d0f12;
border-color: transparent;
font-weight: 800;
box-shadow: 0 8px 20px rgba(198, 255, 58, 0.22);
}
.btn-neon:hover { box-shadow: 0 10px 26px rgba(198, 255, 58, 0.34); }
.btn-danger { color: var(--danger); border-color: rgba(248, 113, 113, 0.4); }
.btn-danger:hover { background: rgba(248, 113, 113, 0.12); }
/* ---------- KPIs ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 22px;
}
.kpi {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px 18px 8px;
box-shadow: var(--sh-1);
position: relative;
overflow: hidden;
}
.kpi-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.kpi-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.kpi-trend {
font-size: 12px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
}
.kpi-trend.up { color: var(--ok); background: rgba(52, 211, 153, 0.14); }
.kpi-trend.down { color: var(--ok); background: rgba(52, 211, 153, 0.14); }
.kpi-value {
font-size: 30px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 8px 0 4px;
}
.spark {
width: 100%;
height: 36px;
display: block;
}
.spark polyline {
fill: none;
stroke: var(--neon);
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 2px 6px rgba(198, 255, 58, 0.35));
}
.spark.warn polyline { stroke: var(--orange); filter: drop-shadow(0 2px 6px rgba(255, 106, 43, 0.35)); }
/* ---------- Panel ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.search {
position: relative;
flex: 1 1 240px;
max-width: 360px;
}
.search svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 17px;
height: 17px;
stroke: var(--muted);
stroke-width: 2;
fill: none;
stroke-linecap: round;
pointer-events: none;
}
.search input {
width: 100%;
padding: 10px 12px 10px 38px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
color: var(--ink);
font-size: 14px;
}
.search input::placeholder { color: var(--muted); }
.search input:focus { border-color: var(--line-2); outline: none; box-shadow: 0 0 0 3px var(--neon-50); }
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
}
.chip:hover { color: var(--ink); border-color: var(--line-2); }
.chip.is-active {
background: var(--neon-50);
border-color: rgba(198, 255, 58, 0.4);
color: var(--neon);
}
/* ---------- Bulk bar ---------- */
.bulkbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 18px;
background: var(--neon-50);
border-bottom: 1px solid rgba(198, 255, 58, 0.25);
animation: slideDown 0.18s ease;
}
@keyframes slideDown { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.bulk-count { font-size: 14px; color: var(--ink-2); }
.bulk-count strong { color: var(--neon); }
.bulk-actions { display: flex; gap: 8px; }
.bulk-clear {
margin-left: auto;
background: transparent;
border: none;
color: var(--muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.bulk-clear:hover { color: var(--ink); }
/* ---------- Table ---------- */
.table-wrap { overflow-x: auto; }
.members {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
.members thead th {
text-align: left;
padding: 12px 14px;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
}
.members th.num, .members td.num { text-align: right; }
.col-check { width: 44px; }
.col-actions { width: 120px; text-align: right; }
.sort {
background: none;
border: none;
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
}
.sort:hover { color: var(--ink); }
.caret {
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
opacity: 0;
transition: opacity 0.15s;
}
.sort.asc .caret { opacity: 1; border-bottom: 5px solid var(--neon); }
.sort.desc .caret { opacity: 1; border-top: 5px solid var(--neon); }
.sort.asc, .sort.desc { color: var(--ink); }
.members tbody td {
padding: 13px 14px;
border-bottom: 1px solid var(--line);
font-size: 14px;
color: var(--ink-2);
vertical-align: middle;
}
.members tbody tr { transition: background 0.12s ease; cursor: pointer; }
.members tbody tr:hover { background: var(--surface-2); }
.members tbody tr.is-selected { background: var(--neon-50); }
.members tbody tr:last-child td { border-bottom: none; }
.member-cell { display: flex; align-items: center; gap: 11px; }
.avatar {
width: 34px; height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #0d0f12;
flex: none;
}
.member-name { color: var(--ink); font-weight: 600; }
.member-email { font-size: 12px; color: var(--muted); }
.tier {
font-weight: 700;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tier-elite { color: var(--neon); }
.tier-pro { color: var(--orange); }
.tier-basic { color: var(--ink-2); }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
border-radius: 999px;
text-transform: capitalize;
}
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.active { color: var(--ok); background: rgba(52, 211, 153, 0.13); }
.pill.frozen { color: #7dd3fc; background: rgba(125, 211, 252, 0.13); }
.pill.trial { color: var(--warn); background: rgba(251, 191, 36, 0.13); }
.pill.lapsed { color: var(--danger); background: rgba(248, 113, 113, 0.13); }
.ltv { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
.row-actions { display: flex; gap: 6px; justify-content: flex-end; }
.icon-btn {
width: 30px; height: 30px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--ink-2);
display: grid;
place-items: center;
cursor: pointer;
font-size: 14px;
transition: all 0.13s ease;
}
.icon-btn:hover { color: var(--ink); border-color: var(--line-2); background: var(--elevated); }
.icon-btn svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.col-check input, .members thead input {
width: 16px; height: 16px;
accent-color: var(--neon);
cursor: pointer;
}
.empty {
padding: 48px 20px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
/* ---------- Pager ---------- */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
.pager-info { font-size: 13px; color: var(--muted); }
.pager-btns { display: flex; align-items: center; gap: 8px; }
.pager-pages { display: flex; gap: 6px; }
.page-num {
min-width: 32px;
height: 32px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--ink-2);
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.page-num.is-active { background: var(--neon); color: #0d0f12; border-color: transparent; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ---------- Drawer ---------- */
.drawer-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
z-index: 40;
animation: fade 0.18s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.drawer {
position: fixed;
top: 0; right: 0;
height: 100%;
width: 400px;
max-width: 92vw;
background: var(--surface);
border-left: 1px solid var(--line-2);
box-shadow: var(--sh-3);
z-index: 50;
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
overflow-y: auto;
padding: 22px 22px 32px;
}
.drawer.is-open { transform: translateX(0); }
.drawer-close {
position: absolute;
top: 14px; right: 16px;
background: transparent;
border: none;
color: var(--muted);
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.drawer-close:hover { color: var(--ink); }
.d-head { display: flex; align-items: center; gap: 14px; margin: 6px 0 18px; }
.d-avatar {
width: 58px; height: 58px;
border-radius: 50%;
display: grid; place-items: center;
font-size: 21px; font-weight: 800; color: #0d0f12;
flex: none;
}
.d-name { font-size: 20px; font-weight: 800; margin: 0; letter-spacing: -0.01em; }
.d-email { color: var(--muted); font-size: 13px; }
.d-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 18px 0;
}
.d-stat {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px;
text-align: center;
}
.d-stat-v { font-size: 18px; font-weight: 800; color: var(--ink); }
.d-stat-l { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); margin-top: 2px; }
.d-section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin: 22px 0 10px;
}
.d-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
font-size: 14px;
}
.d-row span:first-child { color: var(--muted); }
.d-row span:last-child { color: var(--ink); font-weight: 600; }
.d-actions { display: flex; gap: 10px; margin-top: 22px; }
.d-actions .btn { flex: 1; }
/* ---------- Toasts ---------- */
.toast-stack {
position: fixed;
bottom: 22px; left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 80;
width: max-content;
max-width: 92vw;
}
.toast {
background: var(--elevated);
border: 1px solid var(--line-2);
border-left: 3px solid var(--neon);
color: var(--ink);
padding: 12px 16px;
border-radius: var(--r-md);
font-size: 14px;
font-weight: 500;
box-shadow: var(--sh-2);
animation: toastIn 0.22s ease;
}
.toast.danger { border-left-color: var(--danger); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 520px) {
.shell { padding: 18px 14px 48px; }
.brand-title { font-size: 20px; }
.topbar-actions { width: 100%; }
.topbar-actions .btn { flex: 1; }
.kpis { grid-template-columns: 1fr 1fr; gap: 10px; }
.kpi { padding: 14px 14px 6px; }
.kpi-value { font-size: 24px; }
.toolbar { padding: 14px; }
.search { max-width: none; }
.filters { width: 100%; }
.pager { justify-content: center; }
.drawer { width: 100%; max-width: 100%; }
}
/* Visibility guard: honor the [hidden] attribute over base display */
.bulkbar[hidden] {
display: none;
}(function () {
"use strict";
// ---------- Data ----------
var AVATAR_COLORS = ["#c6ff3a", "#ff6a2b", "#34d399", "#7dd3fc", "#fbbf24", "#f472b6", "#a78bfa"];
var MEMBERS = [
{ id: 1, name: "Marcus Vega", email: "[email protected]", tier: "elite", status: "active", joined: "2022-03-14", lastVisit: "2026-06-07", ltv: 4280, plan: "Annual Elite", visits: 412, trainer: "Dana Cole", phone: "(415) 555-0142" },
{ id: 2, name: "Priya Nandakumar", email: "[email protected]", tier: "pro", status: "active", joined: "2023-11-02", lastVisit: "2026-06-06", ltv: 1860, plan: "Monthly Pro", visits: 188, trainer: "Leo Marsh", phone: "(415) 555-0119" },
{ id: 3, name: "Tobias Lind", email: "[email protected]", tier: "basic", status: "frozen", joined: "2024-01-20", lastVisit: "2026-04-11", ltv: 720, plan: "Monthly Basic", visits: 64, trainer: "—", phone: "(415) 555-0188" },
{ id: 4, name: "Renee Okafor", email: "[email protected]", tier: "elite", status: "active", joined: "2021-08-05", lastVisit: "2026-06-08", ltv: 5910, plan: "Annual Elite", visits: 603, trainer: "Dana Cole", phone: "(415) 555-0173" },
{ id: 5, name: "Hassan Karim", email: "[email protected]", tier: "pro", status: "trial", joined: "2026-05-29", lastVisit: "2026-06-05", ltv: 0, plan: "14-day Trial", visits: 5, trainer: "Leo Marsh", phone: "(415) 555-0151" },
{ id: 6, name: "Camila Soto", email: "[email protected]", tier: "pro", status: "active", joined: "2023-02-18", lastVisit: "2026-06-07", ltv: 2340, plan: "Monthly Pro", visits: 271, trainer: "Nina Park", phone: "(415) 555-0107" },
{ id: 7, name: "Derek Onyango", email: "[email protected]", tier: "basic", status: "lapsed", joined: "2023-06-30", lastVisit: "2026-02-14", ltv: 540, plan: "Monthly Basic", visits: 41, trainer: "—", phone: "(415) 555-0166" },
{ id: 8, name: "Yuki Tanaka", email: "[email protected]", tier: "elite", status: "active", joined: "2022-10-12", lastVisit: "2026-06-08", ltv: 3780, plan: "Annual Elite", visits: 389, trainer: "Nina Park", phone: "(415) 555-0124" },
{ id: 9, name: "Bianca Ferreira", email: "[email protected]", tier: "pro", status: "frozen", joined: "2024-04-09", lastVisit: "2026-05-01", ltv: 1120, plan: "Monthly Pro", visits: 97, trainer: "Leo Marsh", phone: "(415) 555-0193" },
{ id: 10, name: "Omar Haddad", email: "[email protected]", tier: "basic", status: "active", joined: "2025-01-22", lastVisit: "2026-06-04", ltv: 410, plan: "Monthly Basic", visits: 58, trainer: "—", phone: "(415) 555-0138" },
{ id: 11, name: "Sloane Whitaker", email: "[email protected]", tier: "elite", status: "active", joined: "2021-12-01", lastVisit: "2026-06-06", ltv: 6240, plan: "Annual Elite", visits: 671, trainer: "Dana Cole", phone: "(415) 555-0102" },
{ id: 12, name: "Diego Ramos", email: "[email protected]", tier: "pro", status: "trial", joined: "2026-06-01", lastVisit: "2026-06-07", ltv: 0, plan: "14-day Trial", visits: 4, trainer: "Nina Park", phone: "(415) 555-0177" },
{ id: 13, name: "Ingrid Holm", email: "[email protected]", tier: "basic", status: "active", joined: "2024-09-15", lastVisit: "2026-06-03", ltv: 890, plan: "Monthly Basic", visits: 112, trainer: "—", phone: "(415) 555-0145" },
{ id: 14, name: "Nadia Petrova", email: "[email protected]", tier: "pro", status: "lapsed", joined: "2023-03-27", lastVisit: "2026-01-09", ltv: 1480, plan: "Monthly Pro", visits: 156, trainer: "Leo Marsh", phone: "(415) 555-0181" },
{ id: 15, name: "Theo Castellanos", email: "[email protected]", tier: "elite", status: "active", joined: "2022-07-19", lastVisit: "2026-06-08", ltv: 4015, plan: "Annual Elite", visits: 433, trainer: "Dana Cole", phone: "(415) 555-0128" },
{ id: 16, name: "Amara Bello", email: "[email protected]", tier: "pro", status: "active", joined: "2024-02-11", lastVisit: "2026-06-05", ltv: 1690, plan: "Monthly Pro", visits: 143, trainer: "Nina Park", phone: "(415) 555-0159" },
{ id: 17, name: "Logan Frost", email: "[email protected]", tier: "basic", status: "frozen", joined: "2025-03-08", lastVisit: "2026-05-20", ltv: 300, plan: "Monthly Basic", visits: 33, trainer: "—", phone: "(415) 555-0190" },
{ id: 18, name: "Mei Lin Zhao", email: "[email protected]", tier: "elite", status: "active", joined: "2021-05-23", lastVisit: "2026-06-07", ltv: 6890, plan: "Annual Elite", visits: 720, trainer: "Dana Cole", phone: "(415) 555-0111" },
];
var PAGE_SIZE = 8;
// ---------- State ----------
var state = {
search: "",
filter: "all",
sortKey: "name",
sortDir: "asc",
page: 1,
selected: new Set(),
};
// ---------- Elements ----------
var $ = function (sel) { return document.querySelector(sel); };
var tbody = $("#tbody");
var emptyEl = $("#empty");
var searchInput = $("#search");
var selectAll = $("#selectAll");
var bulkbar = $("#bulkbar");
var bulkCount = $("#bulkCount");
var pagerInfo = $("#pagerInfo");
var pagerPages = $("#pagerPages");
var prevBtn = $("#prevPage");
var nextBtn = $("#nextPage");
var drawer = $("#drawer");
var scrim = $("#scrim");
var drawerBody = $("#drawerBody");
var toasts = $("#toasts");
// ---------- Helpers ----------
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind === "danger" ? " danger" : "");
el.textContent = msg;
toasts.appendChild(el);
setTimeout(function () {
el.style.transition = "opacity .25s ease, transform .25s ease";
el.style.opacity = "0";
el.style.transform = "translateY(8px)";
setTimeout(function () { el.remove(); }, 250);
}, 2600);
}
function initials(name) {
return name.split(" ").map(function (p) { return p[0]; }).slice(0, 2).join("").toUpperCase();
}
function colorFor(id) { return AVATAR_COLORS[id % AVATAR_COLORS.length]; }
function fmtDate(iso) {
var d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtMoney(n) { return "$" + n.toLocaleString("en-US"); }
function getFiltered() {
var q = state.search.trim().toLowerCase();
var list = MEMBERS.filter(function (m) {
if (state.filter !== "all" && m.status !== state.filter) return false;
if (q && m.name.toLowerCase().indexOf(q) === -1 && m.email.toLowerCase().indexOf(q) === -1) return false;
return true;
});
var dir = state.sortDir === "asc" ? 1 : -1;
var key = state.sortKey;
list.sort(function (a, b) {
var av = a[key], bv = b[key];
if (typeof av === "string") { av = av.toLowerCase(); bv = bv.toLowerCase(); }
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return list;
}
// ---------- Render ----------
function render() {
var list = getFiltered();
var totalPages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
if (state.page > totalPages) state.page = totalPages;
var start = (state.page - 1) * PAGE_SIZE;
var pageItems = list.slice(start, start + PAGE_SIZE);
tbody.innerHTML = "";
emptyEl.hidden = list.length !== 0;
pageItems.forEach(function (m) {
var tr = document.createElement("tr");
tr.dataset.id = m.id;
if (state.selected.has(m.id)) tr.classList.add("is-selected");
tr.innerHTML =
'<td class="col-check"><input type="checkbox" aria-label="Select ' + m.name + '"' + (state.selected.has(m.id) ? " checked" : "") + "></td>" +
'<td><div class="member-cell">' +
'<span class="avatar" style="background:' + colorFor(m.id) + '">' + initials(m.name) + "</span>" +
'<span><span class="member-name">' + m.name + "</span><br><span class=\"member-email\">" + m.email + "</span></span>" +
"</div></td>" +
'<td><span class="tier tier-' + m.tier + '">' + m.tier + "</span></td>" +
'<td><span class="pill ' + m.status + '">' + m.status + "</span></td>" +
"<td>" + fmtDate(m.joined) + "</td>" +
"<td>" + fmtDate(m.lastVisit) + "</td>" +
'<td class="num"><span class="ltv">' + fmtMoney(m.ltv) + "</span></td>" +
'<td class="col-actions"><div class="row-actions">' +
'<button class="icon-btn" data-act="freeze" title="Freeze"><svg viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg></button>' +
'<button class="icon-btn" data-act="message" title="Message"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>' +
'<button class="icon-btn" data-act="view" title="View"><svg viewBox="0 0 24 24"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>' +
"</div></td>";
tbody.appendChild(tr);
});
// pager
pagerInfo.textContent = list.length === 0
? "No results"
: "Showing " + (start + 1) + "–" + Math.min(start + PAGE_SIZE, list.length) + " of " + list.length;
pagerPages.innerHTML = "";
for (var p = 1; p <= totalPages; p++) {
var b = document.createElement("button");
b.className = "page-num" + (p === state.page ? " is-active" : "");
b.textContent = p;
b.dataset.page = p;
pagerPages.appendChild(b);
}
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= totalPages;
// select-all state reflects current page
var pageIds = pageItems.map(function (m) { return m.id; });
var allSelected = pageIds.length > 0 && pageIds.every(function (id) { return state.selected.has(id); });
selectAll.checked = allSelected;
selectAll.indeterminate = !allSelected && pageIds.some(function (id) { return state.selected.has(id); });
updateBulkbar();
}
function updateBulkbar() {
var n = state.selected.size;
bulkbar.hidden = n === 0;
bulkCount.textContent = n;
}
function findMember(id) {
for (var i = 0; i < MEMBERS.length; i++) if (MEMBERS[i].id === id) return MEMBERS[i];
return null;
}
// ---------- Drawer ----------
function openDrawer(m) {
drawerBody.innerHTML =
'<div class="d-head">' +
'<span class="d-avatar" style="background:' + colorFor(m.id) + '">' + initials(m.name) + "</span>" +
'<div><h2 class="d-name">' + m.name + '</h2><div class="d-email">' + m.email + "</div></div>" +
"</div>" +
'<div><span class="pill ' + m.status + '">' + m.status + '</span> <span class="tier tier-' + m.tier + '">' + m.tier + " tier</span></div>" +
'<div class="d-stats">' +
'<div class="d-stat"><div class="d-stat-v">' + m.visits + '</div><div class="d-stat-l">Visits</div></div>' +
'<div class="d-stat"><div class="d-stat-v">' + fmtMoney(m.ltv) + '</div><div class="d-stat-l">LTV</div></div>' +
'<div class="d-stat"><div class="d-stat-v">' + fmtDate(m.lastVisit).split(",")[0] + '</div><div class="d-stat-l">Last visit</div></div>' +
"</div>" +
'<div class="d-section-title">Membership</div>' +
'<div class="d-row"><span>Plan</span><span>' + m.plan + "</span></div>" +
'<div class="d-row"><span>Joined</span><span>' + fmtDate(m.joined) + "</span></div>" +
'<div class="d-row"><span>Assigned trainer</span><span>' + m.trainer + "</span></div>" +
'<div class="d-section-title">Contact</div>' +
'<div class="d-row"><span>Email</span><span>' + m.email + "</span></div>" +
'<div class="d-row"><span>Phone</span><span>' + m.phone + "</span></div>" +
'<div class="d-actions">' +
'<button class="btn btn-sm" type="button" data-d="message">Message</button>' +
'<button class="btn btn-sm btn-neon" type="button" data-d="freeze">Freeze</button>' +
"</div>";
drawerBody.querySelector('[data-d="message"]').addEventListener("click", function () { toast("Message sent to " + m.name); });
drawerBody.querySelector('[data-d="freeze"]').addEventListener("click", function () { freezeMember(m); });
drawer.classList.add("is-open");
drawer.setAttribute("aria-hidden", "false");
scrim.hidden = false;
drawer.focus();
}
function closeDrawer() {
drawer.classList.remove("is-open");
drawer.setAttribute("aria-hidden", "true");
scrim.hidden = true;
}
function freezeMember(m) {
m.status = m.status === "frozen" ? "active" : "frozen";
toast(m.name + " is now " + m.status + ".");
render();
closeDrawer();
}
// ---------- Events ----------
searchInput.addEventListener("input", function (e) {
state.search = e.target.value;
state.page = 1;
render();
});
document.querySelectorAll(".chip").forEach(function (chip) {
chip.addEventListener("click", function () {
document.querySelectorAll(".chip").forEach(function (c) { c.classList.remove("is-active"); });
chip.classList.add("is-active");
state.filter = chip.dataset.filter;
state.page = 1;
render();
});
});
document.querySelectorAll(".sort").forEach(function (btn) {
btn.addEventListener("click", function () {
var key = btn.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
} else {
state.sortKey = key;
state.sortDir = "asc";
}
document.querySelectorAll(".sort").forEach(function (s) { s.classList.remove("asc", "desc"); });
btn.classList.add(state.sortDir);
render();
});
});
tbody.addEventListener("click", function (e) {
var actBtn = e.target.closest("[data-act]");
var checkbox = e.target.closest('input[type="checkbox"]');
var tr = e.target.closest("tr");
if (!tr) return;
var id = Number(tr.dataset.id);
var m = findMember(id);
if (checkbox) {
if (checkbox.checked) state.selected.add(id);
else state.selected.delete(id);
tr.classList.toggle("is-selected", checkbox.checked);
updateBulkbar();
var pageIds = Array.prototype.map.call(tbody.querySelectorAll("tr"), function (r) { return Number(r.dataset.id); });
selectAll.checked = pageIds.every(function (pid) { return state.selected.has(pid); });
return;
}
if (actBtn) {
var act = actBtn.dataset.act;
if (act === "view") openDrawer(m);
else if (act === "freeze") freezeMember(m);
else if (act === "message") toast("Message sent to " + m.name + ".");
return;
}
openDrawer(m);
});
selectAll.addEventListener("change", function () {
var pageIds = Array.prototype.map.call(tbody.querySelectorAll("tr"), function (r) { return Number(r.dataset.id); });
pageIds.forEach(function (id) {
if (selectAll.checked) state.selected.add(id);
else state.selected.delete(id);
});
render();
});
$("#bulkClear").addEventListener("click", function () {
state.selected.clear();
render();
});
document.querySelectorAll("[data-bulk]").forEach(function (btn) {
btn.addEventListener("click", function () {
var n = state.selected.size;
var act = btn.dataset.bulk;
if (act === "freeze") {
state.selected.forEach(function (id) { var m = findMember(id); if (m) m.status = "frozen"; });
toast("Froze " + n + " member" + (n === 1 ? "" : "s") + ".");
} else if (act === "message") {
toast("Messaged " + n + " member" + (n === 1 ? "" : "s") + ".");
} else if (act === "cancel") {
state.selected.forEach(function (id) { var m = findMember(id); if (m) m.status = "lapsed"; });
toast("Cancelled " + n + " membership" + (n === 1 ? "" : "s") + ".", "danger");
}
state.selected.clear();
render();
});
});
prevBtn.addEventListener("click", function () { if (state.page > 1) { state.page--; render(); } });
nextBtn.addEventListener("click", function () { state.page++; render(); });
pagerPages.addEventListener("click", function (e) {
var b = e.target.closest(".page-num");
if (b) { state.page = Number(b.dataset.page); render(); }
});
$("#drawerClose").addEventListener("click", closeDrawer);
scrim.addEventListener("click", closeDrawer);
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeDrawer(); });
$("#exportBtn").addEventListener("click", function () {
toast("Exported " + getFiltered().length + " members to CSV.");
});
$("#addBtn").addEventListener("click", function () {
toast("New member form opened.");
});
// initial sort indicator
document.querySelector('.sort[data-sort="name"]').classList.add("asc");
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gym — Members Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">PF</div>
<div class="brand-text">
<span class="brand-eyebrow">Pulse Fitness</span>
<h1 class="brand-title">Members Admin</h1>
</div>
</div>
<div class="topbar-actions">
<button class="btn btn-ghost" type="button" id="exportBtn">Export CSV</button>
<button class="btn btn-neon" type="button" id="addBtn">+ Add member</button>
</div>
</header>
<section class="kpis" aria-label="Key metrics">
<article class="kpi">
<div class="kpi-head">
<span class="kpi-label">Active members</span>
<span class="kpi-trend up">+4.2%</span>
</div>
<div class="kpi-value">1,284</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,30 15,28 30,24 45,25 60,18 75,20 90,12 105,10 120,6" />
</svg>
</article>
<article class="kpi">
<div class="kpi-head">
<span class="kpi-label">New this month</span>
<span class="kpi-trend up">+12</span>
</div>
<div class="kpi-value">87</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,28 15,22 30,26 45,16 60,20 75,12 90,16 105,8 120,11" />
</svg>
</article>
<article class="kpi">
<div class="kpi-head">
<span class="kpi-label">Churn rate</span>
<span class="kpi-trend down">-0.6%</span>
</div>
<div class="kpi-value">3.1%</div>
<svg class="spark warn" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,8 15,12 30,10 45,16 60,14 75,20 90,18 105,24 120,26" />
</svg>
</article>
<article class="kpi">
<div class="kpi-head">
<span class="kpi-label">MRR</span>
<span class="kpi-trend up">+5.8%</span>
</div>
<div class="kpi-value">$94.6k</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true">
<polyline points="0,32 15,29 30,30 45,24 60,22 75,18 90,16 105,10 120,7" />
</svg>
</article>
</section>
<section class="panel" aria-label="Members">
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" id="search" placeholder="Search members…" aria-label="Search members" autocomplete="off" />
</div>
<div class="filters" role="group" aria-label="Filter by status">
<button class="chip is-active" type="button" data-filter="all">All</button>
<button class="chip" type="button" data-filter="active">Active</button>
<button class="chip" type="button" data-filter="frozen">Frozen</button>
<button class="chip" type="button" data-filter="trial">Trial</button>
<button class="chip" type="button" data-filter="lapsed">Lapsed</button>
</div>
</div>
<div class="bulkbar" id="bulkbar" hidden>
<span class="bulk-count"><strong id="bulkCount">0</strong> selected</span>
<div class="bulk-actions">
<button class="btn btn-sm" type="button" data-bulk="freeze">Freeze</button>
<button class="btn btn-sm" type="button" data-bulk="message">Message</button>
<button class="btn btn-sm btn-danger" type="button" data-bulk="cancel">Cancel</button>
</div>
<button class="bulk-clear" type="button" id="bulkClear" aria-label="Clear selection">×</button>
</div>
<div class="table-wrap">
<table class="members" aria-label="Members table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="selectAll" aria-label="Select all" /></th>
<th><button class="sort" type="button" data-sort="name">Member <span class="caret"></span></button></th>
<th><button class="sort" type="button" data-sort="tier">Tier <span class="caret"></span></button></th>
<th><button class="sort" type="button" data-sort="status">Status <span class="caret"></span></button></th>
<th><button class="sort" type="button" data-sort="joined">Joined <span class="caret"></span></button></th>
<th><button class="sort" type="button" data-sort="lastVisit">Last visit <span class="caret"></span></button></th>
<th class="num"><button class="sort" type="button" data-sort="ltv">LTV <span class="caret"></span></button></th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div class="empty" id="empty" hidden>No members match your filters.</div>
</div>
<div class="pager">
<span class="pager-info" id="pagerInfo">—</span>
<div class="pager-btns">
<button class="btn btn-sm" type="button" id="prevPage">Prev</button>
<span class="pager-pages" id="pagerPages"></span>
<button class="btn btn-sm" type="button" id="nextPage">Next</button>
</div>
</div>
</section>
</div>
<div class="drawer-scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" aria-label="Member detail" tabindex="-1">
<button class="drawer-close" type="button" id="drawerClose" aria-label="Close detail">×</button>
<div id="drawerBody"></div>
</aside>
<div class="toast-stack" id="toasts" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Members Admin
A staff-facing console for running a gym’s membership base. The header surfaces four KPI cards — active members, new this month, churn rate and MRR — each with a trend badge and an inline SVG sparkline so the headline number lands with context. Below it, a search box, status filter chips (All / Active / Frozen / Trial / Lapsed) and a sortable data table list every member with their avatar, tier, status pill, join date, last visit and lifetime value.
Every column header is a sort toggle that flips between ascending and descending with a live caret, and the table re-renders instantly as you search or filter. Checkboxes drive a bulk-select flow: the header checkbox selects the current page, and once any row is checked a neon bulk-action bar appears to freeze, message or cancel the selection in one move. Results are paginated, with prev/next and numbered page buttons.
Clicking a row — or its view action — slides in a right-hand detail drawer showing the member’s profile, visit count, LTV, plan, assigned trainer and contact details, with quick message and freeze buttons. Row-level freeze, message and view actions, a CSV export and an add-member trigger all fire toast confirmations. Everything is plain HTML, CSS and vanilla JavaScript with no dependencies.
Illustrative UI only — names, members and metrics are fictional sample data.