/* =========================================================================
dashboard.js — Page 1 logic.
Talks to Supabase only through the DB layer (db.js) and uses shared UI
helpers (script.js). No framework.
========================================================================= */
(function () {
'use strict';
var el = {
loading: document.getElementById('loadingState'),
empty: document.getElementById('emptyState'),
emptyTitle:document.getElementById('emptyTitle'),
emptyText: document.getElementById('emptyText'),
toolbar: document.getElementById('toolbar'),
tableWrap: document.getElementById('tableWrap'),
tbody: document.getElementById('tableBody'),
search: document.getElementById('searchInput'),
filter: document.getElementById('statusFilter'),
count: document.getElementById('resultCount'),
refresh: document.getElementById('refreshBtn'),
updated: document.getElementById('lastUpdated'),
errBanner: document.getElementById('errorBanner'),
errText: document.getElementById('errorText'),
errRetry: document.getElementById('errorRetry'),
thead: document.querySelector('#table thead'),
};
var state = {
all: [],
sortKey: 'created_at',
sortDir: 'desc', // 'asc' | 'desc'
search: '',
status: '',
};
// ---- View helpers ------------------------------------------------------
function show(node) { if (node) node.hidden = false; }
function hide(node) { if (node) node.hidden = true; }
function setError(message) {
el.errText.textContent = message;
show(el.errBanner);
hide(el.loading);
hide(el.toolbar);
hide(el.tableWrap);
hide(el.empty);
}
function clearError() { hide(el.errBanner); }
function statusClass(status) {
return 'st-' + String(status || 'New').replace(/\s+/g, '');
}
function digits(v) { return String(v || '').replace(/\D/g, ''); }
// ---- Filtering + sorting ----------------------------------------------
function matchesSearch(r, q) {
if (!q) return true;
var needle = q.trim().toLowerCase();
var digitNeedle = digits(q);
var haystack = [r.your_name, r.friend_name, r.referral_code]
.map(function (x) { return String(x || '').toLowerCase(); })
.join(' ');
if (haystack.indexOf(needle) !== -1) return true;
if (digitNeedle) {
if (digits(r.your_phone).indexOf(digitNeedle) !== -1) return true;
if (digits(r.friend_phone).indexOf(digitNeedle) !== -1) return true;
}
return false;
}
function compare(a, b, key) {
var av = a[key], bv = b[key];
if (key === 'created_at') {
return new Date(av || 0) - new Date(bv || 0);
}
if (key === 'sale_verified') {
return (av ? 1 : 0) - (bv ? 1 : 0);
}
av = String(av == null ? '' : av).toLowerCase();
bv = String(bv == null ? '' : bv).toLowerCase();
return av < bv ? -1 : av > bv ? 1 : 0;
}
function currentView() {
var rows = state.all.filter(function (r) {
if (state.status && r.status !== state.status) return false;
return matchesSearch(r, state.search);
});
rows.sort(function (a, b) {
var c = compare(a, b, state.sortKey);
return state.sortDir === 'asc' ? c : -c;
});
return rows;
}
// ---- Rendering ---------------------------------------------------------
var copyIcon =
'';
function copyBtn(value, label) {
if (!value) return '';
return (
''
);
}
function statusSelect(r) {
var opts = DB.STATUSES.map(function (s) {
return '';
}).join('');
return (
''
);
}
function saleToggle(r) {
return (
''
);
}
function emailCell(email) {
if (!email) return '—';
return (
'' +
UI.escapeHtml(email) + '' + copyBtn(email, 'email') + ''
);
}
function phoneCell(phone) {
if (!phone) return '—';
return (
'' +
UI.escapeHtml(phone) + '' + copyBtn(phone, 'phone') + ''
);
}
function rowHtml(r) {
var notes = r.notes
? '
' + UI.escapeHtml(r.notes) + ' | '
: 'No notes | ';
return (
'' +
'| ' +
UI.escapeHtml(r.referral_code || '—') + '' +
copyBtn(r.referral_code, 'Referral ID') + ' | ' +
'' + UI.escapeHtml(UI.shortId(r.id)) + '' +
copyBtn(r.id, 'Row ID') + ' | ' +
'' + UI.escapeHtml(UI.formatDate(r.created_at)) + ' | ' +
'' + UI.escapeHtml(r.your_name || '—') + ' | ' +
'' + phoneCell(r.your_phone) + ' | ' +
'' + emailCell(r.your_email) + ' | ' +
'' + UI.escapeHtml(r.friend_name || '—') + ' | ' +
'' + phoneCell(r.friend_phone) + ' | ' +
'' + emailCell(r.friend_email) + ' | ' +
'' + statusSelect(r) + ' | ' +
notes +
'' + saleToggle(r) + ' | ' +
'
'
);
}
function updateSortIndicators() {
var ths = el.thead.querySelectorAll('th.sortable');
ths.forEach(function (th) {
var ind = th.querySelector('.sort-ind');
if (ind) ind.remove();
if (th.getAttribute('data-key') === state.sortKey) {
var span = document.createElement('span');
span.className = 'sort-ind';
span.textContent = state.sortDir === 'asc' ? '▲' : '▼';
th.appendChild(span);
}
});
}
function render() {
var rows = currentView();
updateSortIndicators();
// Count label
var total = state.all.length;
el.count.textContent =
rows.length === total
? total + (total === 1 ? ' referral' : ' referrals')
: rows.length + ' of ' + total + ' shown';
if (total === 0) {
hide(el.tableWrap);
el.emptyTitle.textContent = 'No referrals yet';
el.emptyText.textContent =
'When someone submits the referral form, it will appear here.';
show(el.empty);
return;
}
if (rows.length === 0) {
hide(el.tableWrap);
el.emptyTitle.textContent = 'No matches';
el.emptyText.textContent =
'No referrals match your search or filter. Try clearing them.';
show(el.empty);
return;
}
hide(el.empty);
el.tbody.innerHTML = rows.map(rowHtml).join('');
show(el.tableWrap);
}
// ---- Referral ID backfill (safety net) --------------------------------
// The database trigger assigns codes on insert, so this normally does
// nothing. If any legacy row is missing a code, generate one and persist it.
async function backfillCodes() {
var missing = state.all.filter(function (r) { return !r.referral_code; });
for (var i = 0; i < missing.length; i++) {
var r = missing[i];
var code = DB.generateReferralId(r.created_at);
try {
var saved = await DB.backfillReferralCode(r.id, code);
r.referral_code = saved.referral_code || code;
} catch (err) {
// If a unique collision or permission issue occurs, show the generated
// code locally so the UI still works; it just isn't persisted.
r.referral_code = code;
}
}
}
// ---- Data load ---------------------------------------------------------
async function load() {
clearError();
show(el.loading);
hide(el.toolbar);
hide(el.tableWrap);
hide(el.empty);
if (!DB.ready) {
hide(el.loading);
setError(
'Supabase isn\u2019t configured. Open config.js and fill in your project ' +
'URL and publishable key (the same ones your landing page uses).'
);
return;
}
try {
state.all = await DB.fetchReferrals();
await backfillCodes();
hide(el.loading);
show(el.toolbar);
el.updated.textContent =
'Updated ' + new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
render();
} catch (err) {
hide(el.loading);
var msg = err && err.message ? err.message : 'Unknown error.';
if (/relation|does not exist|schema cache|column/i.test(msg)) {
msg = 'The referrals table isn\u2019t set up for the dashboard yet. Run ' +
'supabase/dashboard-migration.sql in the Supabase SQL editor, then retry.';
} else if (/Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED/i.test(msg)) {
msg = 'Could not reach Supabase. Check that the project URL in config.js ' +
'is correct and the project is active, then retry.';
} else if (/JWT|api key|Invalid/i.test(msg)) {
msg = 'The Supabase key was rejected. Confirm the publishable key in ' +
'config.js belongs to this project, then retry.';
}
setError(msg);
}
}
// ---- Events ------------------------------------------------------------
el.search.addEventListener('input', function (e) {
state.search = e.target.value;
render();
});
el.filter.addEventListener('change', function (e) {
state.status = e.target.value;
render();
});
el.refresh.addEventListener('click', load);
el.errRetry.addEventListener('click', load);
// Sorting
el.thead.addEventListener('click', function (e) {
var th = e.target.closest('th.sortable');
if (!th) return;
var key = th.getAttribute('data-key');
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = key === 'created_at' ? 'desc' : 'asc';
}
render();
});
// Row interactions (delegated)
el.tbody.addEventListener('click', function (e) {
// Copy buttons
var cb = e.target.closest('.copybtn');
if (cb) {
e.stopPropagation();
UI.copy(cb.getAttribute('data-copy'), cb.getAttribute('data-label'));
return;
}
// Ignore clicks on the interactive controls
if (e.target.closest('.status-select') || e.target.closest('.toggle') ||
e.target.closest('a')) {
return;
}
// Otherwise open the contact page for this row
var tr = e.target.closest('tr.row-clickable');
if (tr) {
window.location.href = 'contact.html?id=' + encodeURIComponent(tr.getAttribute('data-id'));
}
});
// Status change (delegated)
el.tbody.addEventListener('change', async function (e) {
var target = e.target;
var id = target.getAttribute('data-id');
var role = target.getAttribute('data-role');
if (!id || !role) return;
if (role === 'status') {
var newStatus = target.value;
var prev = findLocal(id) ? findLocal(id).status : null;
target.className = 'status-select ' + statusClass(newStatus);
try {
var saved = await DB.updateStatus(id, newStatus);
applyLocal(id, saved);
UI.toast('Status updated', 'success');
} catch (err) {
target.value = prev; // revert
target.className = 'status-select ' + statusClass(prev);
UI.toast('Could not update status', 'error');
}
}
if (role === 'sale') {
var verified = target.checked;
var label = target.parentNode.querySelector('.toggle__label');
if (label) label.textContent = verified ? 'Yes' : 'No';
try {
var savedS = await DB.updateSaleVerified(id, verified);
applyLocal(id, savedS);
UI.toast(verified ? 'Marked sale verified' : 'Sale verification cleared', 'success');
} catch (err) {
target.checked = !verified; // revert
if (label) label.textContent = !verified ? 'Yes' : 'No';
UI.toast('Could not update sale verification', 'error');
}
}
});
function findLocal(id) {
for (var i = 0; i < state.all.length; i++) {
if (String(state.all[i].id) === String(id)) return state.all[i];
}
return null;
}
function applyLocal(id, saved) {
var r = findLocal(id);
if (r && saved) Object.keys(saved).forEach(function (k) { r[k] = saved[k]; });
}
// Go
load();
})();