[object Object]

← 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

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 →