← back to Prestige Car Wash
Admin: lead-status pipeline (new→contacted→booked/won/lost) — dtd:C, contrarian-gated (revert-on-fail + in-place update)
7c77409990ba8523b200a23873cc6ab258d75401 · 2026-07-26 15:06:01 -0700 · steve@designerwallcoverings.com
Files touched
M public/admin/index.htmlM server.js
Diff
commit 7c77409990ba8523b200a23873cc6ab258d75401
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Sun Jul 26 15:06:01 2026 -0700
Admin: lead-status pipeline (new→contacted→booked/won/lost) — dtd:C, contrarian-gated (revert-on-fail + in-place update)
---
public/admin/index.html | 35 +++++++++++++++++++++++++++++++----
server.js | 26 ++++++++++++++++++++++++--
2 files changed, 55 insertions(+), 6 deletions(-)
diff --git a/public/admin/index.html b/public/admin/index.html
index ee474aa..afd3fa8 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -73,6 +73,8 @@
.cost-card.model{border-color:var(--brand);border-left-width:3px}
.cost-card.model h5{text-transform:none}
@media(max-width:560px){.swot-grid{grid-template-columns:1fr}}
+ .lead-pipe{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:12px;color:var(--mut)}
+ .lead-pipe select{background:var(--panel2);color:var(--ink,#e9eef5);border:1px solid var(--line);border-radius:8px;padding:5px 8px;font:inherit;font-size:12.5px}
/* --- Action Brief button + modal (dtd:A, 2026-07-26) --- */
.brief-btn{margin-top:10px;background:var(--panel);border:1px solid var(--line);color:var(--brand);border-radius:8px;padding:6px 11px;font-size:12px;font-weight:700;cursor:pointer}
.brief-btn:hover{border-color:var(--brand)}
@@ -187,13 +189,17 @@ const TABS=[
<div class="ttl">${enc(d.name)}</div><div class="blurb">${enc(d.notes)}</div>
${d.url?`<a class="tag" href="${enc(d.url)}" target="_blank" style="margin-top:6px;display:inline-block">open ↗</a>`:''}${when(d.created_at)}`)},
{id:'google',label:'🟢 Google Business'},
- {id:'leads',label:'📨 Leads',api:'/api/admin/leads',search:['name','service','message'],
- card:l=>cardWrap(`<div class="cat">${enc(l.service||'general')}</div>
+ {id:'leads',label:'📨 Leads',api:'/api/admin/leads',search:['name','service','message','status'],
+ card:l=>cardWrap(`<div class="row"><div class="cat">${enc(l.service||'general')}</div>
+ <span class="badge ${leadBadge(l.status)}">${enc(l.status||'new')}</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>Vehicle</span><span>${enc(l.vehicle)}</span></div>
- <div class="blurb">${enc(l.message)}</div>${when(l.created_at)}`)},
+ <div class="blurb">${enc(l.message)}</div>
+ <label class="lead-pipe">Pipeline <select data-id="${enc(l.id||l.created_at)}" onchange="setLeadStatus(this)">
+ ${['new','contacted','booked','won','lost'].map(s=>`<option value="${s}"${(l.status||'new')===s?' selected':''}>${s}</option>`).join('')}
+ </select></label>${when(l.created_at)}`)},
{id:'credentials',label:'🔑 Credentials'}
];
let CUR='overview';
@@ -204,6 +210,26 @@ window.go=id=>{closeBrief();CUR=id;document.querySelectorAll('#tabs button').for
$('#q').value='';$('#sort').dataset.tab='';render();};
function cardWrap(inner){return `<div class="card"><div class="body">${inner}</div></div>`;}
+// 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';}
+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);
+ const prev=row?row.status:'new';
+ fetch('/api/admin/lead-status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,status})})
+ .then(r=>r.json()).then(j=>{
+ sel.disabled=false;
+ if(j&&j.ok){
+ if(row) row.status=status;
+ // update the badge IN PLACE — no full re-render, so scroll position + the owner's
+ // spot in the list are preserved (contrarian gate, 2026-07-26).
+ const card=sel.closest('.card'), badge=card&&card.querySelector('.badge');
+ if(badge){ badge.textContent=status; badge.className='badge '+leadBadge(status); }
+ } else { sel.value=prev; } // revert so the control never lies about what saved
+ })
+ .catch(()=>{ sel.disabled=false; sel.value=prev; });
+};
// Expandable SWOT · costs · payback panel for an idea card. Renders only the
// sub-objects that exist, so ideas without analysis are unaffected (additive).
function swotCol(title,arr,cls){
@@ -251,7 +277,8 @@ const SORTKEYS={
services:[{label:'Price ↑',field:'price',numeric:true,dir:1},{label:'Price ↓',field:'price',numeric:true,dir:-1}],
ads:[{label:'Budget ↓',field:'budget_month',numeric:true,dir:-1}],
holidays:[{label:'Date ↑',field:'date',dir:1}],
- directories:[{label:'Priority',field:'priority',dir:1}]
+ directories:[{label:'Priority',field:'priority',dir:1}],
+ leads:[{label:'Status',field:'status',dir:1}]
};
const TITLEFIELD={suggestions:'title',competitors:'name',services:'name',ads:'headline',holidays:'name',directories:'name',leads:'name'};
function sortOptsFor(id){
diff --git a/server.js b/server.js
index 3a050ea..dfae8b7 100644
--- a/server.js
+++ b/server.js
@@ -285,6 +285,7 @@ app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
const b = req.body || {};
if (!b.name || !(b.phone || b.email)) return res.status(400).json({ ok: false, error: 'name + phone/email required' });
const lead = {
+ id: 'ld_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name: cap(b.name, 120), phone: cap(b.phone, 40), email: cap(b.email, 160), vehicle: cap(b.vehicle, 120),
service: cap(b.service, 120), preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
created_at: new Date().toISOString(), source: 'web-form'
@@ -304,14 +305,35 @@ app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
res.json({ ok: true, message: "Thanks! We'll follow up to confirm your booking — usually within a few hours." });
});
-// Admin: view captured leads.
+// Lead pipeline status — kept in a SEPARATE json map (keyed by lead id, or created_at for
+// legacy leads) so the append-only leads.jsonl log is never rewritten. dtd:C 2026-07-26.
+const LEAD_STATUSES = ['new', 'contacted', 'booked', 'won', 'lost'];
+const leadStatusPath = () => path.join(__dirname, 'reports', 'lead-status.json');
+function readLeadStatus() { try { return JSON.parse(fs.readFileSync(leadStatusPath(), 'utf8')); } catch { return {}; } }
+
+// Admin: view captured leads, each merged with its pipeline status (default 'new').
app.get('/api/admin/leads', (req, res) => {
try {
const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
- res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
+ const st = readLeadStatus();
+ const leads = raw ? raw.split('\n').map(l => JSON.parse(l)) : [];
+ leads.forEach(l => { l.status = st[l.id || l.created_at] || 'new'; });
+ res.json(leads.reverse());
} catch { res.json([]); }
});
+// 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 || {};
+ if (!id || !LEAD_STATUSES.includes(status)) return res.status(400).json({ ok: false, error: 'id + valid status required' });
+ try {
+ const st = readLeadStatus(); st[id] = status;
+ fs.mkdirSync(path.join(__dirname, 'reports'), { recursive: true });
+ fs.writeFileSync(leadStatusPath(), JSON.stringify(st, null, 2));
+ res.json({ ok: true, id, status });
+ } catch { res.status(500).json({ ok: false, error: 'could not save status' }); }
+});
+
// Public: HONEST "typical wait" estimate derived from the demand model.
// It is NOT a live queue count — it's a transparent function of best-times.json
// demand for the current weekday, always labeled as an estimate, so we never
← 157f32a SEO: enrich AutoWash JSON-LD with hasOfferCatalog (real serv
·
back to Prestige Car Wash
·
Admin Overview: lead-funnel summary (status counts, new-this 53de6fd →