Node.js · Vercel Functions · Chart.js · Dashboard
Orbit Analytics
A 3-page SaaS admin dashboard — KPI cards, a live revenue chart, and a searchable/sortable/paginated customer table.
An overview page with KPI cards and a 30-day revenue chart, a customer directory with server-side search, sorting and pagination (not client-side array filtering), and a settings page that actually persists. The ~64 customers and the revenue history are generated with a seeded PRNG, so the data is identical across every request without needing a database — the kind of dashboard a SaaS client would actually ask for.
Live demo
embedconst { generateCustomers } = require('./_customers');
const ALL = generateCustomers();
module.exports = (req, res) => {
if (req.method !== 'GET') {
res.status(405).json({ error: 'method not allowed' });
return;
}
const q = (req.query.q || '').toString().toLowerCase().trim();
const status = (req.query.status || '').toString();
const sort = (req.query.sort || 'joinedAt:desc').toString();
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const perPage = Math.min(50, Math.max(5, parseInt(req.query.perPage, 10) || 10));
let rows = ALL;
if (q) {
rows = rows.filter((c) =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q)
);
}
if (status && status !== 'all') {
rows = rows.filter((c) => c.status === status);
}
const [field, dir] = sort.split(':');
const sorted = [...rows].sort((a, b) => {
let av = a[field];
let bv = b[field];
if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); }
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
const total = sorted.length;
const start = (page - 1) * perPage;
const pageRows = sorted.slice(start, start + perPage);
res.status(200).json({
customers: pageRows,
total,
page,
perPage,
totalPages: Math.max(1, Math.ceil(total / perPage)),
});
};
const rowsEl = document.getElementById('rows');
const paginationEl = document.getElementById('pagination');
const searchEl = document.getElementById('search');
const statusEl = document.getElementById('status-filter');
let state = { q: '', status: 'all', sortField: 'joinedAt', sortDir: 'desc', page: 1 };
let debounceTimer = null;
function updateSortArrows() {
document.querySelectorAll('th[data-sort]').forEach((th) => {
const arrow = th.querySelector('.arrow');
if (th.dataset.sort === state.sortField) {
arrow.textContent = state.sortDir === 'asc' ? '↑' : '↓';
} else {
arrow.textContent = '';
}
});
}
async function load() {
const params = new URLSearchParams({
q: state.q,
status: state.status,
sort: `${state.sortField}:${state.sortDir}`,
page: state.page,
perPage: 10,
});
const res = await fetch(`/api/customers?${params}`);
const data = await res.json();
render(data);
}
function render(data) {
if (!data.customers.length) {
rowsEl.innerHTML = '<tr><td colspan="6"><p class="empty">No customers match your filters.</p></td></tr>';
} else {
rowsEl.innerHTML = data.customers.map((c) => `
<tr>
<td>
<div class="cell-name">${c.name}</div>
<div class="cell-sub">${c.email}</div>
</td>
<td>${c.company}</td>
<td>${c.plan}</td>
<td>${c.mrr ? money(c.mrr) : '—'}</td>
<td><span class="pill ${c.status}">${c.status}</span></td>
<td class="cell-sub">${c.joinedAt}</td>
</tr>
`).join('');
}
paginationEl.innerHTML = `
<span>${data.total} customer${data.total === 1 ? '' : 's'} · page ${data.page} of ${data.totalPages}</span>
<div class="btns">
<button type="button" id="prev-page" ${data.page <= 1 ? 'disabled' : ''}>← Prev</button>
<button type="button" id="next-page" ${data.page >= data.totalPages ? 'disabled' : ''}>Next →</button>
</div>
`;
document.getElementById('prev-page').addEventListener('click', () => { state.page--; load(); });
document.getElementById('next-page').addEventListener('click', () => { state.page++; load(); });
updateSortArrows();
}
document.querySelectorAll('th[data-sort]').forEach((th) => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (state.sortField === field) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortField = field;
state.sortDir = 'asc';
}
state.page = 1;
load();
});
});
searchEl.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
state.q = searchEl.value;
state.page = 1;
load();
}, 250);
});
statusEl.addEventListener('change', () => {
state.status = statusEl.value;
state.page = 1;
load();
});
load();