[object Object]

← back to Pitch Guard

Pitch panel: Edit/Preview HTML toggle + Open-as-page; strip George From-job banner

bfffaf905b33ee7861c23fd392cf8fd29897992b · 2026-05-19 10:09:55 -0700 · Steve

Files touched

Diff

commit bfffaf905b33ee7861c23fd392cf8fd29897992b
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 10:09:55 2026 -0700

    Pitch panel: Edit/Preview HTML toggle + Open-as-page; strip George From-job banner
---
 public/index.html | 40 +++++++++++++++++++++++++++++++++++++---
 server.js         | 30 ++++++++++++++++++++++++++++++
 2 files changed, 67 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4d5cfb2..385682c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -176,6 +176,13 @@
   .toast.show{opacity:1}
   .toast.err{background:var(--red)}
   .sent-stamp{font-size:11px;color:var(--green);font-weight:700}
+  .body-tabs{display:flex;align-items:center;gap:6px;margin-bottom:5px}
+  .bodytab{font-size:11px;padding:3px 10px;border:1px solid var(--line);border-radius:5px;
+    background:var(--paper);color:var(--muted);cursor:pointer;text-decoration:none;line-height:1.6}
+  .bodytab.active{background:var(--ink);color:var(--cream);border-color:var(--ink)}
+  .bodytab:hover{border-color:var(--gold)}
+  .body-preview{border:1px solid var(--line);border-radius:6px;padding:16px 18px;background:#fff;
+    min-height:230px;font-size:13px;line-height:1.65;color:var(--ink);overflow-wrap:anywhere}
   body.solo .rz-handle,body.solo .grip,body.solo [data-act="popout"]{display:none}
 </style>
 </head>
@@ -629,12 +636,29 @@ async function toggleMsg(node){
   }
 }
 
+// George prepends an internal "From job:" banner to drafts — strip it for clean view/edit
+function stripBanner(s){ return String(s||'').replace(/<div style="font-family:ui-monospace[^>]*>[\s\S]*?<\/div>\s*/i,''); }
+function renderEmailHTML(text){
+  if(/<(br|div|p|a|span|strong|em|ul|ol|li|table|h[1-6])\b/i.test(text)) return text;
+  return esc(text).replace(/\n/g,'<br>');
+}
+function setBodyMode(mode){
+  const ta=$('#pBody'), pv=$('#pPreview'), be=$('#btEdit'), bp=$('#btPrev');
+  if(!ta||!pv) return;
+  if(mode==='preview'){
+    pv.innerHTML=renderEmailHTML(ta.value); pv.style.display='block'; ta.style.display='none';
+    bp&&bp.classList.add('active'); be&&be.classList.remove('active');
+  } else {
+    ta.style.display='block'; pv.style.display='none';
+    be&&be.classList.add('active'); bp&&bp.classList.remove('active');
+  }
+}
 async function loadPitch(d){
   const el = $('#pitch');
   if(!el) return; // pitch panel not in this window
   el.innerHTML = '<div class="pitch-empty"><div class="spin"></div> loading draft…</div>';
   let body='';
-  try{ const r=await fetch('/api/message/'+d.id); const m=await r.json(); body=m.body||''; }
+  try{ const r=await fetch('/api/message/'+d.id); const m=await r.json(); body=stripBanner(m.body||''); }
   catch(e){ body='(could not load draft body: '+e.message+')'; }
   el.innerHTML =
     '<div class="pitch">'+
@@ -642,8 +666,16 @@ async function loadPitch(d){
         '<div id="verdictSlot"></div>'+
         '<div class="fld"><label>To</label><div class="ro">'+esc(d.to||'(no recipient)')+'</div></div>'+
         '<div class="fld"><label>Subject</label><input id="pSubj" value="'+esc(d.subject)+'"></div>'+
-        '<div class="fld"><label>Pitch / followup body — edit before sending</label>'+
-          '<textarea id="pBody">'+esc(body)+'</textarea></div>'+
+        '<div class="fld">'+
+          '<div class="body-tabs">'+
+            '<label style="margin:0;flex:1">Pitch / followup body — edit before sending</label>'+
+            '<button type="button" id="btEdit" class="bodytab active">✎ Edit</button>'+
+            '<button type="button" id="btPrev" class="bodytab">👁 Preview</button>'+
+            '<a href="/api/draft/'+d.id+'/html" target="_blank" rel="noopener" class="bodytab" title="Open the rendered email as a page">↗ Page</a>'+
+          '</div>'+
+          '<textarea id="pBody">'+esc(body)+'</textarea>'+
+          '<div id="pPreview" class="body-preview" style="display:none"></div>'+
+        '</div>'+
       '</div>'+
       '<div class="pitch-foot">'+
         '<span id="sendState"></span><span class="spacer"></span>'+
@@ -655,6 +687,8 @@ async function loadPitch(d){
   $('#skipBtn').onclick=()=>{ toast('Skipped — draft left untouched in Gmail'); };
   $('#delBtn').onclick=()=>deleteDraft(d.id);
   $('#sendBtn').onclick=()=>sendPitch(d);
+  $('#btEdit').onclick=()=>setBodyMode('edit');
+  $('#btPrev').onclick=()=>setBodyMode('preview');
   const c = verdictOf(d.toEmail);
   if(c) renderVerdict(c);
   else if(d.toEmail) $('#verdictSlot').innerHTML='<div class="verdict caution"><h3><span class="spin"></span> Checking every thread with this contact…</h3></div>';
diff --git a/server.js b/server.js
index 6a66b05..9e90fd2 100644
--- a/server.js
+++ b/server.js
@@ -49,6 +49,12 @@ function displayName(s) {
   return m ? m[1].trim() : extractEmail(s);
 }
 const OURS = 'designerwallcoverings.com';
+function escHtml(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
+// George prepends an internal "From job:" provenance banner to every draft —
+// strip it so the customer-facing view (and edits) stay clean.
+function stripJobBanner(s) {
+  return String(s || '').replace(/<div style="font-family:ui-monospace[^>]*>[\s\S]*?<\/div>\s*/i, '');
+}
 
 // Signals that a contact has likely already transacted — the Samantha tripwire.
 const SIGNALS = [
@@ -358,6 +364,30 @@ app.post('/api/followup/create', async (req, res) => {
   } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
 });
 
+// ── API: render a draft as a front-facing HTML email page ──────────────────
+app.get('/api/draft/:id/html', async (req, res) => {
+  try {
+    const m = await george('/api/messages/' + encodeURIComponent(req.params.id), { query: { account: ACCOUNT } });
+    const body = stripJobBanner(m.body || '');
+    const looksHtml = /<(br|div|p|a|span|strong|em|ul|ol|li|table|h[1-6])\b/i.test(body);
+    const rendered = looksHtml ? body : escHtml(body).replace(/\n/g, '<br>');
+    res.set('Content-Type', 'text/html; charset=utf-8').send(
+      '<!doctype html><html><head><meta charset="utf-8">' +
+      '<meta name="viewport" content="width=device-width,initial-scale=1">' +
+      '<title>' + escHtml(m.subject || 'Draft') + '</title><style>' +
+      'body{margin:0;background:#e7e4dc;font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#26221c}' +
+      '.wrap{max-width:660px;margin:34px auto;background:#fff;border-radius:10px;box-shadow:0 3px 16px rgba(0,0,0,.13);overflow:hidden}' +
+      '.hd{padding:18px 28px;border-bottom:1px solid #ece8df;background:#faf8f3}' +
+      '.hd .s{font:600 18px Georgia,"Times New Roman",serif}' +
+      '.hd .m{font-size:12px;color:#8a8273;margin-top:4px}' +
+      '.bd{padding:28px}.bd img{max-width:100%}' +
+      '</style></head><body><div class="wrap">' +
+      '<div class="hd"><div class="s">' + escHtml(m.subject || '(no subject)') + '</div>' +
+      '<div class="m">To: ' + escHtml(m.to || '—') + ' &nbsp;·&nbsp; From: ' + escHtml(m.from || ('info@' + OURS)) + '</div></div>' +
+      '<div class="bd">' + rendered + '</div></div></body></html>');
+  } catch (e) { res.status(e.status || 500).send('<pre>' + escHtml(e.message) + '</pre>'); }
+});
+
 app.get('/api/health', (req, res) => res.json({ ok: true, george: GEORGE, account: ACCOUNT }));
 
 app.listen(PORT, () => console.log(`Pitch Guard → http://127.0.0.1:${PORT}  (info@${OURS} via George)`));

← 164394a Add 5-day followup candidate scanner (dry-run) — Followup Ca  ·  back to Pitch Guard  ·  Send no_source_tag to George so customer emails skip the Fro b5dbc8d →