← 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
M public/index.htmlM server.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 || '—') + ' · 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 →