Coworking — Billing & Subscriptions
A warm-industrial billing and subscriptions console for a fictional coworking space, built with plain HTML, CSS and vanilla JavaScript. It pairs an animated revenue rollup — MRR, active members, outstanding balance, collected — with a filterable members table showing plan, status, MRR and next invoice, plus dunning flags on past-due accounts. A recent-invoices list opens a sliding detail drawer with line items and tax, where you can mark paid or send reminders, change a member plan, and run a dunning sweep.
MCP
Code
:root {
--concrete: #efeae3;
--concrete-d: #e2dcd2;
--amber: #e8902b;
--amber-d: #cc7918;
--amber-50: #fdf1e2;
--char: #1c1b19;
--ink: #26241f;
--ink-2: #4a463e;
--muted: #7b766c;
--bg: #f6f3ee;
--surface: #ffffff;
--plant: #5f7a52;
--line: rgba(28, 27, 25, 0.1);
--line-2: rgba(28, 27, 25, 0.18);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--occupied: #d4503e;
--free: #2f9e6f;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.06), 0 1px 3px rgba(28, 27, 25, 0.05);
--shadow-md: 0 6px 20px rgba(28, 27, 25, 0.1);
--shadow-lg: 0 18px 50px rgba(28, 27, 25, 0.22);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Layout */
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
background: var(--char);
color: var(--concrete);
padding: 22px 18px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--amber);
color: var(--char);
font-size: 22px;
font-weight: 800;
}
.brand-text strong {
display: block;
font-size: 17px;
letter-spacing: -0.01em;
}
.brand-text small {
color: var(--muted);
font-size: 12px;
}
.nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-link {
display: block;
padding: 10px 12px;
border-radius: var(--r-sm);
color: rgba(239, 234, 227, 0.72);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--concrete);
}
.nav-link.active {
background: var(--amber);
color: var(--char);
font-weight: 600;
}
.nav-link:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
.side-card {
margin-top: auto;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--r-md);
padding: 16px;
font-size: 13px;
color: rgba(239, 234, 227, 0.85);
}
.side-card .plant {
font-size: 20px;
}
.side-card p {
margin: 8px 0 12px;
}
/* Main */
.main {
padding: 28px 34px 48px;
max-width: 1280px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 26px;
}
.topbar h1 {
margin: 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--char);
}
.sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.cycle-pill {
font-size: 13px;
color: var(--ink-2);
background: var(--concrete);
border: 1px solid var(--line);
padding: 8px 12px;
border-radius: 999px;
}
/* Buttons */
.btn {
font-family: inherit;
font-size: 14px;
font-weight: 600;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 10px 16px;
cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--amber-d);
outline-offset: 2px;
}
.btn-amber {
background: var(--amber);
color: var(--char);
box-shadow: var(--shadow-sm);
}
.btn-amber:hover {
background: var(--amber-d);
}
.btn-ghost {
background: transparent;
border-color: var(--line-2);
color: var(--ink);
}
.btn-ghost:hover {
background: var(--concrete);
}
.sidebar .btn-ghost {
border-color: rgba(255, 255, 255, 0.18);
color: var(--concrete);
}
.sidebar .btn-ghost:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn-sm {
padding: 7px 12px;
font-size: 13px;
}
/* Summary stats */
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.stat-value {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--char);
}
.stat-value.ok {
color: var(--ok);
}
.stat-value.warn {
color: var(--warn);
}
.stat-trend {
font-size: 12.5px;
color: var(--muted);
}
.stat-trend.up {
color: var(--ok);
font-weight: 600;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: 16px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--char);
}
.hint {
font-size: 12px;
color: var(--muted);
}
/* Filters */
.filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.field input,
.field select {
font-family: inherit;
font-size: 13px;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 8px 10px;
}
.field input:focus,
.field select:focus {
outline: 2px solid var(--amber);
outline-offset: 0;
border-color: var(--amber);
}
.field input[type="search"] {
min-width: 180px;
}
.field.block {
display: block;
margin: 14px 0;
}
.field.block > span {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.field.block select {
width: 100%;
}
/* Members table */
.table-wrap {
overflow-x: auto;
}
table.members {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
table.members th {
text-align: left;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
font-weight: 600;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
background: var(--concrete);
}
table.members th.num,
table.members td.num {
text-align: right;
}
table.members td {
padding: 12px 16px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
table.members tbody tr {
transition: background 0.12s ease;
}
table.members tbody tr:hover {
background: var(--amber-50);
}
table.members tbody tr:last-child td {
border-bottom: none;
}
.member-cell {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: var(--surface);
flex-shrink: 0;
}
.member-name {
font-weight: 600;
color: var(--ink);
}
.member-email {
font-size: 12px;
color: var(--muted);
}
.plan-tag {
display: inline-block;
font-size: 12px;
font-weight: 600;
padding: 3px 9px;
border-radius: 999px;
background: var(--concrete);
border: 1px solid var(--line);
color: var(--ink-2);
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
}
.status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.status.active {
color: var(--ok);
}
.status.past_due {
color: var(--danger);
}
.status.paused {
color: var(--warn);
}
.status.canceled {
color: var(--muted);
}
.dunning-flag {
display: inline-block;
margin-left: 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--danger);
background: rgba(212, 80, 62, 0.12);
padding: 2px 6px;
border-radius: 4px;
}
.mrr {
font-weight: 700;
color: var(--char);
}
.next-inv {
font-size: 13px;
color: var(--ink-2);
}
.row-action {
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: var(--amber-d);
background: transparent;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 6px 11px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease;
}
.row-action:hover {
background: var(--amber-50);
border-color: var(--amber);
}
.row-action:focus-visible {
outline: 2px solid var(--amber-d);
outline-offset: 1px;
}
.empty {
padding: 28px 20px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.panel-foot {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
font-size: 13px;
color: var(--muted);
border-top: 1px solid var(--line);
background: var(--bg);
}
.panel-foot strong {
color: var(--char);
}
/* Invoices */
.invoices {
list-style: none;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 560px;
overflow-y: auto;
}
.invoice-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 12px;
align-items: center;
width: 100%;
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 12px 14px;
cursor: pointer;
font-family: inherit;
transition: background 0.12s ease, border-color 0.12s ease;
}
.invoice-row:hover {
background: var(--bg);
border-color: var(--line);
}
.invoice-row:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 1px;
}
.invoice-id {
font-weight: 700;
font-size: 13.5px;
color: var(--char);
}
.invoice-for {
grid-column: 1;
font-size: 12.5px;
color: var(--muted);
}
.invoice-amount {
grid-row: 1;
grid-column: 2;
font-weight: 700;
font-size: 14px;
color: var(--char);
}
.invoice-status {
grid-row: 2;
grid-column: 2;
justify-self: end;
font-size: 11.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.invoice-status.paid {
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
.invoice-status.open {
color: var(--amber-d);
background: var(--amber-50);
}
.invoice-status.overdue {
color: var(--danger);
background: rgba(212, 80, 62, 0.12);
}
/* Drawer */
.drawer-scrim {
position: fixed;
inset: 0;
background: rgba(28, 27, 25, 0.4);
backdrop-filter: blur(2px);
z-index: 40;
animation: fade 0.18s ease;
}
.drawer {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: min(420px, 92vw);
background: var(--surface);
box-shadow: var(--shadow-lg);
z-index: 50;
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
}
.drawer.open {
transform: translateX(0);
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 22px;
border-bottom: 1px solid var(--line);
}
.drawer-head h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--char);
}
.icon-btn {
border: 1px solid var(--line-2);
background: var(--surface);
border-radius: var(--r-sm);
width: 34px;
height: 34px;
cursor: pointer;
font-size: 14px;
color: var(--ink-2);
transition: background 0.12s ease;
}
.icon-btn:hover {
background: var(--concrete);
}
.icon-btn:focus-visible {
outline: 2px solid var(--amber-d);
outline-offset: 1px;
}
.drawer-body {
padding: 22px;
overflow-y: auto;
flex: 1;
}
.dr-amount {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--char);
}
.dr-badge {
display: inline-block;
margin-bottom: 18px;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
}
.dr-meta {
list-style: none;
margin: 0 0 20px;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.dr-meta li {
display: flex;
justify-content: space-between;
padding: 11px 14px;
font-size: 13.5px;
border-bottom: 1px solid var(--line);
}
.dr-meta li:last-child {
border-bottom: none;
}
.dr-meta span:first-child {
color: var(--muted);
}
.dr-meta span:last-child {
font-weight: 600;
color: var(--ink);
}
.dr-lines {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-bottom: 20px;
}
.dr-lines th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.dr-lines td {
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.dr-lines td:last-child,
.dr-lines th:last-child {
text-align: right;
}
.dr-total {
display: flex;
justify-content: space-between;
font-size: 15px;
font-weight: 800;
color: var(--char);
padding-top: 6px;
}
.dr-actions {
display: flex;
gap: 10px;
margin-top: 22px;
}
.dr-actions .btn {
flex: 1;
}
/* Modal */
.modal-scrim {
position: fixed;
inset: 0;
background: rgba(28, 27, 25, 0.42);
backdrop-filter: blur(2px);
z-index: 60;
display: grid;
place-items: center;
padding: 20px;
animation: fade 0.18s ease;
}
.modal {
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
padding: 26px;
width: min(420px, 100%);
animation: pop 0.2s ease;
}
.modal h2 {
margin: 0;
font-size: 19px;
color: var(--char);
}
.modal-sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
/* Toast */
.toast-wrap {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.toast {
background: var(--char);
color: var(--concrete);
font-size: 13.5px;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--shadow-md);
animation: rise 0.22s ease;
}
.toast.ok {
background: var(--ok);
color: #fff;
}
.toast.warn {
background: var(--amber-d);
color: #fff;
}
@keyframes fade {
from { opacity: 0; }
}
@keyframes pop {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
}
.row-out {
animation: rowfade 0.3s ease forwards;
}
@keyframes rowfade {
to { opacity: 0; transform: translateX(12px); }
}
/* Responsive */
@media (max-width: 980px) {
.app {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 14px;
}
.nav {
flex-direction: row;
flex-wrap: wrap;
flex: 1;
}
.side-card {
margin-top: 0;
flex-basis: 100%;
}
.summary {
grid-template-columns: repeat(2, 1fr);
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.main {
padding: 20px 16px 40px;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
.summary {
grid-template-columns: 1fr;
}
.topbar h1 {
font-size: 22px;
}
.field input[type="search"] {
min-width: 0;
width: 100%;
}
.filters {
width: 100%;
}
.filters .field {
flex: 1;
}
}(function () {
"use strict";
// --- Plan catalog ----------------------------------------------------------
var PLANS = {
"Hot Desk": 180,
"Resident": 340,
"Studio": 620,
"Team": 1450,
};
var AVATAR_COLORS = ["#e8902b", "#5f7a52", "#4a463e", "#cc7918", "#2f9e6f", "#7b766c", "#d4503e"];
// --- Seed data -------------------------------------------------------------
var members = [
{ id: "m1", name: "Nadia Brandt", email: "nadia@orbitstudio.co", plan: "Studio", status: "active", next: "Jul 1" },
{ id: "m2", name: "Theo Marchetti", email: "theo.m@driftlabs.io", plan: "Resident", status: "active", next: "Jul 1" },
{ id: "m3", name: "Priya Anand", email: "priya@anandtype.com", plan: "Team", status: "active", next: "Jul 1" },
{ id: "m4", name: "Oskar Lind", email: "oskar@lind.studio", plan: "Hot Desk", status: "past_due", next: "Jun 19", dunning: true },
{ id: "m5", name: "Mei-Ling Soh", email: "mei@soh.design", plan: "Resident", status: "active", next: "Jul 1" },
{ id: "m6", name: "Carla Bautista", email: "carla@bautista.work", plan: "Studio", status: "paused", next: "—" },
{ id: "m7", name: "Ivan Petrov", email: "ivan@petrov.dev", plan: "Team", status: "past_due", next: "Jun 17", dunning: true },
{ id: "m8", name: "Hana Okafor", email: "hana@okaforco.com", plan: "Hot Desk", status: "active", next: "Jul 1" },
{ id: "m9", name: "Bram de Vries", email: "bram@devries.nl", plan: "Resident", status: "canceled", next: "—" },
{ id: "m10", name: "Sofia Reyes", email: "sofia@reyesfoto.com", plan: "Hot Desk", status: "active", next: "Jul 1" },
{ id: "m11", name: "Jonas Keller", email: "jonas@kellerbuild.de", plan: "Studio", status: "active", next: "Jul 1" },
{ id: "m12", name: "Amara Diallo", email: "amara@diallo.studio", plan: "Resident", status: "active", next: "Jul 1" },
];
var invoices = [
{ id: "INV-2061", member: "m3", forName: "Priya Anand", plan: "Team", amount: 1450, status: "paid", date: "Jun 1, 2026", paid: "Jun 1", method: "Visa ·· 4218" },
{ id: "INV-2060", member: "m1", forName: "Nadia Brandt", plan: "Studio", amount: 620, status: "paid", date: "Jun 1, 2026", paid: "Jun 1", method: "Amex ·· 1007" },
{ id: "INV-2059", member: "m7", forName: "Ivan Petrov", plan: "Team", amount: 1450, status: "overdue", date: "Jun 1, 2026", paid: null, method: "MC ·· 9930" },
{ id: "INV-2058", member: "m4", forName: "Oskar Lind", plan: "Hot Desk", amount: 180, status: "overdue", date: "Jun 1, 2026", paid: null, method: "Visa ·· 5521" },
{ id: "INV-2057", member: "m2", forName: "Theo Marchetti", plan: "Resident", amount: 340, status: "paid", date: "Jun 1, 2026", paid: "Jun 2", method: "Visa ·· 7744" },
{ id: "INV-2056", member: "m11", forName: "Jonas Keller", plan: "Studio", amount: 620, status: "open", date: "Jun 1, 2026", paid: null, method: "SEPA ·· DE21" },
{ id: "INV-2055", member: "m8", forName: "Hana Okafor", plan: "Hot Desk", amount: 180, status: "paid", date: "Jun 1, 2026", paid: "Jun 1", method: "Visa ·· 3360" },
{ id: "INV-2054", member: "m5", forName: "Mei-Ling Soh", plan: "Resident", amount: 340, status: "paid", date: "Jun 1, 2026", paid: "Jun 1", method: "MC ·· 8812" },
];
// --- Helpers ---------------------------------------------------------------
var $ = function (sel) { return document.querySelector(sel); };
var fmt = function (n) { return "$" + n.toLocaleString("en-US"); };
var statusLabel = { active: "Active", past_due: "Past due", paused: "Paused", canceled: "Canceled" };
function initials(name) {
return name.split(" ").map(function (p) { return p[0]; }).slice(0, 2).join("").toUpperCase();
}
function avatarColor(id) {
var sum = 0;
for (var i = 0; i < id.length; i++) sum += id.charCodeAt(i);
return AVATAR_COLORS[sum % AVATAR_COLORS.length];
}
var toastWrap = $("#toastWrap");
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastWrap.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);
}, 2400);
}
// --- Summary rollup --------------------------------------------------------
function billableMembers() {
// MRR counts active + past_due (still owe / still on a plan); paused & canceled excluded.
return members.filter(function (m) { return m.status === "active" || m.status === "past_due"; });
}
function renderSummary() {
var mrr = billableMembers().reduce(function (s, m) { return s + PLANS[m.plan]; }, 0);
var active = members.filter(function (m) { return m.status === "active"; }).length;
var overdueInv = invoices.filter(function (i) { return i.status === "overdue"; });
var outstanding = overdueInv.concat(invoices.filter(function (i) { return i.status === "open"; }))
.reduce(function (s, i) { return s + i.amount; }, 0);
var collected = invoices.filter(function (i) { return i.status === "paid"; })
.reduce(function (s, i) { return s + i.amount; }, 0);
animateValue($("#mrrValue"), mrr, true);
animateValue($("#activeValue"), active, false);
$("#totalMembers").textContent = members.length;
$("#outstandingValue").textContent = fmt(outstanding);
$("#overdueCount").textContent = overdueInv.length + " overdue invoice" + (overdueInv.length === 1 ? "" : "s");
$("#collectedValue").textContent = fmt(collected);
}
function animateValue(el, target, money) {
var start = 0;
var dur = 600;
var t0 = performance.now();
function step(now) {
var p = Math.min((now - t0) / dur, 1);
var eased = 1 - Math.pow(1 - p, 3);
var v = Math.round(start + (target - start) * eased);
el.textContent = money ? fmt(v) : v;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// --- Members table ---------------------------------------------------------
var body = $("#membersBody");
function filteredMembers() {
var q = $("#memberSearch").value.trim().toLowerCase();
var plan = $("#planFilter").value;
var status = $("#statusFilter").value;
return members.filter(function (m) {
if (plan !== "all" && m.plan !== plan) return false;
if (status !== "all" && m.status !== status) return false;
if (q && m.name.toLowerCase().indexOf(q) < 0 && m.email.toLowerCase().indexOf(q) < 0) return false;
return true;
});
}
function renderMembers() {
var rows = filteredMembers();
body.innerHTML = "";
rows.forEach(function (m) {
var tr = document.createElement("tr");
tr.dataset.id = m.id;
var billable = m.status === "active" || m.status === "past_due";
tr.innerHTML =
'<td><div class="member-cell">' +
'<span class="avatar" style="background:' + avatarColor(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="plan-tag">' + m.plan + '</span></td>' +
'<td><span class="status ' + m.status + '">' + statusLabel[m.status] + '</span>' +
(m.dunning ? '<span class="dunning-flag" title="Dunning in progress">Dunning</span>' : '') + '</td>' +
'<td class="num"><span class="mrr">' + (billable ? fmt(PLANS[m.plan]) : "—") + '</span></td>' +
'<td><span class="next-inv">' + m.next + '</span></td>' +
'<td><button class="row-action" data-act="plan" data-id="' + m.id + '">Change plan</button></td>';
body.appendChild(tr);
});
$("#emptyRow").hidden = rows.length !== 0;
$("#memberCount").textContent = rows.length + " member" + (rows.length === 1 ? "" : "s");
var fmrr = rows.filter(function (m) { return m.status === "active" || m.status === "past_due"; })
.reduce(function (s, m) { return s + PLANS[m.plan]; }, 0);
$("#filteredMrr").textContent = fmt(fmrr);
}
$("#memberSearch").addEventListener("input", renderMembers);
$("#planFilter").addEventListener("change", renderMembers);
$("#statusFilter").addEventListener("change", renderMembers);
body.addEventListener("click", function (e) {
var btn = e.target.closest("[data-act='plan']");
if (btn) openPlanModal(btn.dataset.id);
});
// --- Invoices --------------------------------------------------------------
var invStatusLabel = { paid: "Paid", open: "Open", overdue: "Overdue" };
var list = $("#invoiceList");
function renderInvoices() {
list.innerHTML = "";
invoices.forEach(function (inv) {
var li = document.createElement("li");
var btn = document.createElement("button");
btn.className = "invoice-row";
btn.dataset.id = inv.id;
btn.innerHTML =
'<span class="invoice-id">' + inv.id + '</span>' +
'<span class="invoice-for">' + inv.forName + ' · ' + inv.plan + '</span>' +
'<span class="invoice-amount">' + fmt(inv.amount) + '</span>' +
'<span class="invoice-status ' + inv.status + '">' + invStatusLabel[inv.status] + '</span>';
btn.addEventListener("click", function () { openDrawer(inv.id); });
li.appendChild(btn);
list.appendChild(li);
});
}
// --- Invoice drawer --------------------------------------------------------
var drawer = $("#drawer");
var scrim = $("#drawerScrim");
function openDrawer(invId) {
var inv = invoices.find(function (i) { return i.id === invId; });
if (!inv) return;
$("#drawerTitle").textContent = inv.id;
var badgeColors = {
paid: "color:var(--ok);background:rgba(47,158,111,.12)",
open: "color:var(--amber-d);background:var(--amber-50)",
overdue: "color:var(--danger);background:rgba(212,80,62,.12)",
};
var base = inv.amount;
var tax = Math.round(base * 0.08);
var total = base + tax;
var html =
'<span class="dr-badge" style="' + badgeColors[inv.status] + '">' + invStatusLabel[inv.status] + '</span>' +
'<div class="dr-amount">' + fmt(total) + '</div>' +
'<ul class="dr-meta">' +
'<li><span>Billed to</span><span>' + inv.forName + '</span></li>' +
'<li><span>Issued</span><span>' + inv.date + '</span></li>' +
'<li><span>' + (inv.paid ? "Paid on" : "Due") + '</span><span>' + (inv.paid ? inv.paid + ", 2026" : "Jun 19, 2026") + '</span></li>' +
'<li><span>Payment method</span><span>' + inv.method + '</span></li>' +
'</ul>' +
'<table class="dr-lines"><thead><tr><th>Description</th><th>Amount</th></tr></thead><tbody>' +
'<tr><td>' + inv.plan + ' membership — June 2026</td><td>' + fmt(base) + '</td></tr>' +
'<tr><td>Tax (8%)</td><td>' + fmt(tax) + '</td></tr>' +
'</tbody></table>' +
'<div class="dr-total"><span>Total</span><span>' + fmt(total) + '</span></div>';
var actions = '<div class="dr-actions">';
if (inv.status !== "paid") {
actions += '<button class="btn btn-amber" data-act="markpaid" data-id="' + inv.id + '">Mark as paid</button>';
actions += '<button class="btn btn-ghost" data-act="remind" data-id="' + inv.id + '">Send reminder</button>';
} else {
actions += '<button class="btn btn-ghost" data-act="receipt" data-id="' + inv.id + '">Download receipt</button>';
}
actions += '</div>';
$("#drawerBody").innerHTML = html + actions;
scrim.hidden = false;
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
drawer.focus();
}
function closeDrawer() {
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
setTimeout(function () { scrim.hidden = true; }, 260);
}
$("#drawerClose").addEventListener("click", closeDrawer);
scrim.addEventListener("click", closeDrawer);
$("#drawerBody").addEventListener("click", function (e) {
var btn = e.target.closest("[data-act]");
if (!btn) return;
var act = btn.dataset.act;
var id = btn.dataset.id;
if (act === "markpaid") {
var inv = invoices.find(function (i) { return i.id === id; });
if (inv) {
inv.status = "paid";
inv.paid = "Jun 18";
// clear dunning on the member if no more overdue invoices
var mem = members.find(function (m) { return m.id === inv.member; });
if (mem) {
var stillOverdue = invoices.some(function (i) { return i.member === mem.id && i.status !== "paid"; });
if (!stillOverdue && mem.status === "past_due") { mem.status = "active"; mem.dunning = false; mem.next = "Jul 1"; }
}
renderInvoices();
renderMembers();
renderSummary();
toast(id + " marked paid", "ok");
closeDrawer();
}
} else if (act === "remind") {
toast("Payment reminder emailed", "warn");
} else if (act === "receipt") {
toast("Receipt downloaded");
}
});
// --- Change plan modal -----------------------------------------------------
var modalScrim = $("#modalScrim");
var activeMemberId = null;
function openPlanModal(id) {
var m = members.find(function (x) { return x.id === id; });
if (!m) return;
activeMemberId = id;
$("#modalMember").textContent = m.name + " · currently " + m.plan;
$("#modalPlan").value = m.plan;
modalScrim.hidden = false;
$("#modalPlan").focus();
}
function closeModal() {
modalScrim.hidden = true;
activeMemberId = null;
}
$("#modalCancel").addEventListener("click", closeModal);
modalScrim.addEventListener("click", function (e) { if (e.target === modalScrim) closeModal(); });
$("#modalSave").addEventListener("click", function () {
var m = members.find(function (x) { return x.id === activeMemberId; });
if (!m) return closeModal();
var newPlan = $("#modalPlan").value;
if (newPlan === m.plan) {
toast("No change — already on " + newPlan);
return closeModal();
}
var diff = PLANS[newPlan] - PLANS[m.plan];
m.plan = newPlan;
if (m.status === "paused" || m.status === "canceled") { m.status = "active"; m.next = "Jul 1"; }
renderMembers();
renderSummary();
var verb = diff > 0 ? "Upgraded" : "Downgraded";
toast(verb + " to " + newPlan + " (" + (diff > 0 ? "+" : "") + fmt(diff) + "/mo)", diff > 0 ? "ok" : "warn");
closeModal();
});
// --- Dunning sweep ---------------------------------------------------------
$("#runDunning").addEventListener("click", function () {
var flagged = members.filter(function (m) { return m.status === "past_due"; }).length;
flagged += 0;
var overdue = invoices.filter(function (i) { return i.status === "overdue"; }).length;
members.forEach(function (m) { if (m.status === "past_due") m.dunning = true; });
renderMembers();
toast("Dunning sweep sent to " + overdue + " overdue account" + (overdue === 1 ? "" : "s"), "warn");
});
// --- New invoice (demo) ----------------------------------------------------
$("#newInvoiceBtn").addEventListener("click", function () {
toast("Draft invoice created — open it from the list");
});
// --- Keyboard --------------------------------------------------------------
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
if (drawer.classList.contains("open")) closeDrawer();
if (!modalScrim.hidden) closeModal();
}
});
// --- Init ------------------------------------------------------------------
renderMembers();
renderInvoices();
renderSummary();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Looms Coworking — Billing & Subscriptions</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&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◖</span>
<div class="brand-text">
<strong>Looms</strong>
<small>Coworking · Admin</small>
</div>
</div>
<nav class="nav" aria-label="Admin sections">
<a href="#" class="nav-link">Overview</a>
<a href="#" class="nav-link active" aria-current="page">Billing & Subscriptions</a>
<a href="#" class="nav-link">Members</a>
<a href="#" class="nav-link">Floor plan</a>
<a href="#" class="nav-link">Settings</a>
</nav>
<div class="side-card">
<span class="plant" aria-hidden="true">🌿</span>
<p>Billing cycle closes in <strong>4 days</strong>. Run dunning before then.</p>
<button class="btn btn-ghost btn-sm" id="runDunning">Run dunning sweep</button>
</div>
</aside>
<!-- Main -->
<main class="main">
<header class="topbar">
<div>
<h1>Billing & Subscriptions</h1>
<p class="sub">Mira Studio · June 2026 cycle</p>
</div>
<div class="topbar-actions">
<span class="cycle-pill">Cycle: <strong>Jun 1 – Jun 30</strong></span>
<button class="btn btn-amber" id="newInvoiceBtn">+ New invoice</button>
</div>
</header>
<!-- Revenue summary -->
<section class="summary" aria-label="Revenue summary">
<article class="stat">
<span class="stat-label">Monthly recurring revenue</span>
<strong class="stat-value" id="mrrValue">$0</strong>
<span class="stat-trend up">▲ 8.2% vs May</span>
</article>
<article class="stat">
<span class="stat-label">Active members</span>
<strong class="stat-value" id="activeValue">0</strong>
<span class="stat-trend">of <span id="totalMembers">0</span> total</span>
</article>
<article class="stat">
<span class="stat-label">Outstanding</span>
<strong class="stat-value warn" id="outstandingValue">$0</strong>
<span class="stat-trend" id="overdueCount">0 overdue</span>
</article>
<article class="stat">
<span class="stat-label">Collected this cycle</span>
<strong class="stat-value ok" id="collectedValue">$0</strong>
<span class="stat-trend up">▲ on track</span>
</article>
</section>
<div class="grid">
<!-- Members table -->
<section class="panel members-panel">
<div class="panel-head">
<h2>Members</h2>
<div class="filters" role="group" aria-label="Filter members">
<label class="field">
<span class="sr-only">Search members</span>
<input type="search" id="memberSearch" placeholder="Search member or email…" />
</label>
<label class="field">
<span class="sr-only">Filter by plan</span>
<select id="planFilter" aria-label="Filter by plan">
<option value="all">All plans</option>
<option value="Hot Desk">Hot Desk</option>
<option value="Resident">Resident</option>
<option value="Studio">Studio</option>
<option value="Team">Team</option>
</select>
</label>
<label class="field">
<span class="sr-only">Filter by status</span>
<select id="statusFilter" aria-label="Filter by status">
<option value="all">All status</option>
<option value="active">Active</option>
<option value="past_due">Past due</option>
<option value="paused">Paused</option>
<option value="canceled">Canceled</option>
</select>
</label>
</div>
</div>
<div class="table-wrap">
<table class="members" aria-label="Members billing table">
<thead>
<tr>
<th scope="col">Member</th>
<th scope="col">Plan</th>
<th scope="col">Status</th>
<th scope="col" class="num">MRR</th>
<th scope="col">Next invoice</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="membersBody"><!-- rendered --></tbody>
</table>
<p class="empty" id="emptyRow" hidden>No members match these filters.</p>
</div>
<footer class="panel-foot">
<span id="memberCount">0 members</span>
<span>Filtered MRR: <strong id="filteredMrr">$0</strong></span>
</footer>
</section>
<!-- Invoices -->
<section class="panel invoices-panel">
<div class="panel-head">
<h2>Recent invoices</h2>
<span class="hint">Tap a row for detail</span>
</div>
<ul class="invoices" id="invoiceList" aria-label="Recent invoices"><!-- rendered --></ul>
</section>
</div>
</main>
</div>
<!-- Invoice detail drawer -->
<div class="drawer-scrim" id="drawerScrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" aria-label="Invoice detail" role="dialog" tabindex="-1">
<div class="drawer-head">
<h2 id="drawerTitle">Invoice</h2>
<button class="icon-btn" id="drawerClose" aria-label="Close invoice detail">✕</button>
</div>
<div class="drawer-body" id="drawerBody"><!-- rendered --></div>
</aside>
<!-- Change plan dialog -->
<div class="modal-scrim" id="modalScrim" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h2 id="modalTitle">Change plan</h2>
<p class="modal-sub" id="modalMember">—</p>
<label class="field block">
<span>New plan</span>
<select id="modalPlan">
<option value="Hot Desk">Hot Desk — $180/mo</option>
<option value="Resident">Resident — $340/mo</option>
<option value="Studio">Studio — $620/mo</option>
<option value="Team">Team — $1,450/mo</option>
</select>
</label>
<div class="modal-actions">
<button class="btn btn-ghost" id="modalCancel">Cancel</button>
<button class="btn btn-amber" id="modalSave">Save change</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Billing & Subscriptions
A self-contained billing admin for Looms Coworking, dressed in the warm-industrial palette — concrete neutrals, an amber accent, plant-green highlights and a matte-black sidebar. The top of the page rolls up four live figures: monthly recurring revenue, active members, outstanding balance and the amount collected this cycle. The MRR and member counts count up with an eased animation on load and recompute whenever the data changes.
The members table lists each account with an avatar, plan tag, color-coded status dot, MRR and next invoice date; past-due members carry a red Dunning flag. You can search by name or email and filter by plan and status, and the footer keeps a running “filtered MRR” total. The recent-invoices panel opens a sliding drawer with billing details, line items and tax — there you can mark an invoice paid (which clears the member’s dunning state and lifts the rollup), send a reminder, or download a receipt. A Change plan dialog upgrades or downgrades any member and reports the MRR delta, and the sidebar’s dunning sweep flags every overdue account at once.
Everything is keyboard-usable and toasts confirm each action — Escape closes the drawer or dialog.
Illustrative UI only — fictional coworking space, not a real booking system.