[object Object]

← 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

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 →