← back to Prestige Car Wash
Admin: lead worklist (click-to-call/email + follow-up-first order) + CSV export
2062a316ad0a37b7dd9b98f8ce2185a9157dc42e · 2026-07-26 21:41:23 -0700 · Steve Abrams
DTD unanimous 5/5 (A). Leads tab: click-to-call (tel:) + click-to-email (mailto:) on each
card, a 'Needs follow-up' default sort (oldest-cold first, then new, contacted, done), and a
GET /api/admin/leads.csv export (behind requireAdmin, CSV/formula-injection neutralized) with
an Export button. Contrarian GO; applied its two quality fixes: telDigits returns '' for
non-valid lengths (garbage phone renders as plain text, not a dead tel: link) and the worklist
tiebreak surfaces the longest-waiting cold lead first. (Known minor, logged: leads localStorage
sort-index shifts once post-deploy — self-corrects on next pick.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/admin/index.htmlM server.js
Diff
commit 2062a316ad0a37b7dd9b98f8ce2185a9157dc42e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 21:41:23 2026 -0700
Admin: lead worklist (click-to-call/email + follow-up-first order) + CSV export
DTD unanimous 5/5 (A). Leads tab: click-to-call (tel:) + click-to-email (mailto:) on each
card, a 'Needs follow-up' default sort (oldest-cold first, then new, contacted, done), and a
GET /api/admin/leads.csv export (behind requireAdmin, CSV/formula-injection neutralized) with
an Export button. Contrarian GO; applied its two quality fixes: telDigits returns '' for
non-valid lengths (garbage phone renders as plain text, not a dead tel: link) and the worklist
tiebreak surfaces the longest-waiting cold lead first. (Known minor, logged: leads localStorage
sort-index shifts once post-deploy — self-corrects on next pick.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/admin/index.html | 18 +++++++++++++++---
server.js | 23 +++++++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/public/admin/index.html b/public/admin/index.html
index 3549f12..38fc33b 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -193,8 +193,8 @@ const TABS=[
card:l=>cardWrap(`<div class="row"><div class="cat">${enc(l.service||'general')}</div>
<span>${(l.status||'new')==='new'&&isCold(l.created_at)?'<span class="badge b-bad">⏳ cold</span> ':''}<span class="badge ${leadBadge(l.status)}">${enc(l.status||'new')}</span></span></div>
<div class="ttl">${enc(l.name)}</div>
- <div class="kv"><span>Phone</span><span>${enc(l.phone)}</span></div>
- <div class="kv"><span>Email</span><span>${enc(l.email)}</span></div>
+ <div class="kv"><span>Phone</span><span>${l.phone?(telDigits(l.phone)?`<a href="tel:${enc(telDigits(l.phone))}" style="color:var(--brand);font-weight:700">📞 ${enc(l.phone)}</a>`:enc(l.phone)):'—'}</span></div>
+ <div class="kv"><span>Email</span><span>${l.email?`<a href="mailto:${enc(l.email)}" style="color:var(--brand);font-weight:700">✉️ ${enc(l.email)}</a>`:'—'}</span></div>
<div class="kv"><span>Vehicle</span><span>${enc(l.vehicle)}</span></div>
<div class="blurb">${enc(l.message)}</div>
<label class="lead-pipe">Pipeline <select data-id="${enc(l.id||l.created_at)}" onchange="setLeadStatus(this)">
@@ -213,6 +213,14 @@ function cardWrap(inner){return `<div class="card"><div class="body">${inner}</d
// Lead pipeline (dtd:C 2026-07-26): badge colour + status advance. Re-render on save so
// the card re-sorts/re-filters immediately (server re-reads status from disk).
function leadBadge(s){return s==='won'||s==='booked'?'b-good':s==='contacted'?'b-warn':s==='lost'?'b-bad':'b-mut';}
+// One-tap call: normalize a stored phone to a tel: value. Returns '' for lengths that aren't
+// a real US (10, or 11 w/ leading 1) or E.164 international (11–15) number, so a garbage/partial
+// phone renders as plain text rather than a tappable dead link.
+function telDigits(p){let d=String(p||'').replace(/\D/g,'');if(d.length===11&&d[0]==='1')return '+1'+d.slice(1);if(d.length===10)return '+1'+d;if(d.length>=11&&d.length<=15)return '+'+d;return '';}
+// Worklist priority: bucket (uncontacted+cold, then uncontacted, contacted, done) and WITHIN a
+// bucket the oldest-waiting lead first (encode created_at into the key; 1e13 > any ms timestamp
+// so buckets never overlap). Ascending sort => longest-waiting cold lead sits at the very top.
+function leadPriority(l){const s=l.status||'new';const p=(s==='new')?(isCold(l.created_at)?0:1):(s==='contacted'?2:3);const t=l.created_at?new Date(l.created_at).getTime():0;return p*1e13+t;}
window.setLeadStatus=function(sel){
const id=sel.dataset.id, status=sel.value; sel.disabled=true;
const row=(ROWCACHE.leads||[]).find(x=>(x.id||x.created_at)===id);
@@ -284,6 +292,9 @@ const TITLEFIELD={suggestions:'title',competitors:'name',services:'name',ads:'he
function sortOptsFor(id){
const opts=[{label:'Newest',field:'created_at',dir:-1},{label:'Oldest',field:'created_at',dir:1},...(SORTKEYS[id]||[])];
if(TITLEFIELD[id]) opts.push({label:'Title A–Z',field:TITLEFIELD[id],dir:1});
+ // Leads default to a WORKLIST order: uncontacted + cold surface first so the owner works
+ // the follow-ups that convert before browsing the rest.
+ if(id==='leads') opts.unshift({label:'Needs follow-up',calc:leadPriority,numeric:true,dir:1});
return opts;
}
function cmpBy(opt){
@@ -366,7 +377,8 @@ function render(){
const opt=opts[Number(sortEl.value)||0]||opts[0];
list=[...list].sort(cmpBy(opt));
$('#count').textContent=list.length+' item'+(list.length!==1?'s':'');
- $('#view').innerHTML=list.length?gridWrap(list.map(tab.card).join('')):'<p style="color:var(--mut)">Nothing here yet.</p>';
+ const exportBar=tab.id==='leads'?`<div style="margin-bottom:12px"><a href="/api/admin/leads.csv" style="display:inline-block;background:var(--brand);color:#001018;font-weight:700;padding:8px 14px;border-radius:8px;text-decoration:none">⬇ Export leads CSV</a></div>`:'';
+ $('#view').innerHTML=exportBar+(list.length?gridWrap(list.map(tab.card).join('')):'<p style="color:var(--mut)">Nothing here yet.</p>');
});
}
diff --git a/server.js b/server.js
index 45302b6..2651b11 100644
--- a/server.js
+++ b/server.js
@@ -348,6 +348,29 @@ app.get('/api/admin/leads', (req, res) => {
} catch { res.json([]); }
});
+// Admin: export all leads (+ pipeline status) as CSV for offline follow-up / records.
+app.get('/api/admin/leads.csv', (req, res) => {
+ let leads = [];
+ try {
+ const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
+ const st = readLeadStatus();
+ leads = raw ? raw.split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
+ leads.forEach(l => { l.status = st[l.id || l.created_at] || 'new'; });
+ leads.reverse();
+ } catch { /* empty export is still a valid CSV */ }
+ const cols = ['created_at', 'name', 'phone', 'email', 'vehicle', 'service', 'preferred', 'message', 'status'];
+ const esc = v => {
+ let s = String(v == null ? '' : v);
+ if (/^[=+\-@]/.test(s)) s = "'" + s; // neutralize CSV/formula injection
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+ };
+ const out = [cols.join(',')].concat(leads.map(l => cols.map(c => esc(l[c])).join(','))).join('\r\n') + '\r\n';
+ const day = new Date().toISOString().slice(0, 10);
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="prestige-leads-${day}.csv"`);
+ res.send(out);
+});
+
// Admin: advance a lead through the pipeline (new → contacted → booked/won/lost).
app.post('/api/admin/lead-status', (req, res) => {
const { id, status } = req.body || {};
← 7c66f9d Harden booking form: honeypot + validation + dupe-guard + re
·
back to Prestige Car Wash
·
A11y + perf pass: focus-visible, reduced-motion, aria labels acd004a →