← back to Pitch Guard
public/index.html
935 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pitch Guard — Designer Wallcoverings</title>
<style>
:root{
--cream:#f6f3ec; --paper:#fffdf8; --ink:#26221c; --muted:#8a8273;
--line:#e3ddcf; --gold:#9a7b3f; --accent:#2c4a3e;
--red:#a3322a; --redbg:#f7e3e0; --amber:#9a6b1e; --amberbg:#f6ecd6;
--green:#2c6b4f; --greenbg:#e2efe6; --slate:#5a6b78; --slatebg:#e7ecef;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--cream);color:var(--ink);height:100vh;overflow:hidden}
h1,h2,h3,.serif{font-family:Georgia,"Times New Roman",serif}
header{height:54px;background:var(--ink);color:var(--cream);display:flex;align-items:center;
padding:0 18px;gap:14px;border-bottom:3px solid var(--gold);flex-shrink:0}
header .brand{display:flex;align-items:center;gap:9px}
header .brand svg{display:block}
header .brand .wm{display:flex;flex-direction:column;line-height:1}
header .brand .wm b{font-family:Georgia,serif;font-size:13px;letter-spacing:.12em;color:var(--cream)}
header .brand .wm i{font-style:normal;font-size:8.5px;letter-spacing:.26em;color:var(--gold);margin-top:2px}
header .divider{width:1px;height:26px;background:#4a4337}
header h1{font-size:18px;font-weight:600;letter-spacing:.3px}
header .sub{font-size:12px;color:#cdc6b3;letter-spacing:.4px}
header .spacer{flex:1}
header .acct{font-size:12px;background:#3a352b;padding:5px 11px;border-radius:20px;color:#e8e1cd}
header .reset{font-size:11px;background:none;border:1px solid #4a4438;color:#cdc6b3;
padding:4px 10px;border-radius:5px;cursor:pointer}
header .reset:hover{border-color:var(--gold);color:#fff}
.layout{display:flex;height:calc(100vh - 54px);overflow:hidden}
/* ── panels ── */
.panel{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--paper);
flex:0 0 auto;min-width:46px}
.panel.flexed{flex:1 1 0;min-width:300px}
.panel.collapsed{flex:0 0 42px !important;min-width:42px;background:var(--cream)}
.panel.collapsed .pane-body,.panel.collapsed .pane-hd>*:not(.collapse-strip){display:none}
.panel.dragging{opacity:.4}
.panel.drop-target{outline:2px dashed var(--gold);outline-offset:-2px}
.pane-hd{padding:9px 12px;border-bottom:1px solid var(--line);display:flex;
align-items:center;gap:7px;background:var(--paper);flex-shrink:0;min-height:44px;cursor:grab}
.pane-hd:active{cursor:grabbing}
.pane-hd h2{font-size:12px;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);
font-family:inherit;font-weight:700;white-space:nowrap}
.pane-hd .grip{color:var(--line);font-size:13px;cursor:grab;flex-shrink:0;letter-spacing:-2px}
.pane-hd .count{font-size:11px;background:var(--ink);color:var(--cream);border-radius:10px;padding:1px 8px}
.pane-hd .spacer{flex:1}
.hd-btn{font-size:12px;line-height:1;background:none;border:1px solid var(--line);color:var(--muted);
width:24px;height:24px;border-radius:5px;cursor:pointer;flex-shrink:0;padding:0}
.hd-btn:hover{border-color:var(--gold);color:var(--gold);background:var(--cream)}
.collapse-strip{display:none;flex-direction:column;align-items:center;gap:10px;padding-top:4px;cursor:pointer}
.panel.collapsed .pane-hd{flex-direction:column;cursor:pointer;padding:9px 4px;min-height:0}
.panel.collapsed .collapse-strip{display:flex}
.collapse-strip .vlabel{writing-mode:vertical-rl;font-size:11px;text-transform:uppercase;
letter-spacing:1.5px;color:var(--muted);font-weight:700}
.pane-body{flex:1;overflow-y:auto}
/* ── resize handle ── */
.rz-handle{flex:0 0 7px;cursor:col-resize;background:var(--line);position:relative}
.rz-handle:hover,.rz-handle.active{background:var(--gold)}
.rz-handle::after{content:"";position:absolute;left:2px;top:50%;width:3px;height:34px;
transform:translateY(-50%);background:var(--paper);border-radius:2px;opacity:.6}
/* ── buttons ── */
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--paper);
color:var(--ink);border-radius:6px;padding:6px 12px;transition:.12s}
button:hover{background:var(--cream)}
button:disabled{opacity:.45;cursor:not-allowed}
.btn-sm{font-size:11px;padding:4px 9px}
.btn-go{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
.btn-go:hover{background:#233b31}
.btn-danger{background:var(--red);color:#fff;border-color:var(--red)}
/* ── draft list ── */
.draft{padding:10px 14px;border-bottom:1px solid var(--line);cursor:pointer;position:relative}
.draft:hover{background:var(--cream)}
.draft.active{background:var(--cream);box-shadow:inset 3px 0 0 var(--gold)}
.draft .who{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:54px}
.draft .subj{font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.draft .meta{font-size:11px;color:var(--muted);margin-top:3px}
.draft .badge{position:absolute;top:9px;right:12px}
.vgroup-hd{padding:10px 14px;background:var(--cream);border-top:1px solid var(--line);
border-bottom:1px solid var(--line);cursor:pointer;display:flex;align-items:center;gap:7px;
font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.7px}
.vgroup-hd:hover{background:#efeadd}
.vgroup-hd .arrow{font-size:9px;transition:transform .15s}
.vgroup-hd.closed .arrow{transform:rotate(-90deg)}
.vgroup-hd .vct{margin-left:auto;background:var(--slatebg);color:var(--slate);
border-radius:9px;padding:1px 8px;font-size:11px}
.vgroup-note{padding:6px 14px 8px;font-size:11px;color:var(--muted);background:var(--cream)}
.cgroup-hd{padding:10px 14px;background:#eef1ef;border-bottom:1px solid var(--line);
cursor:pointer;display:flex;align-items:center;gap:7px;font-size:11px;font-weight:700;
color:var(--accent);text-transform:uppercase;letter-spacing:.7px}
.cgroup-hd:hover{background:#e6ebe8}
.cgroup-hd .arrow{font-size:9px;transition:transform .15s}
.cgroup-hd.closed .arrow{transform:rotate(-90deg)}
.cgroup-hd .vct{background:var(--accent);color:#fff;border-radius:9px;padding:1px 8px;font-size:11px}
.cgroup-hd .scan-btn{margin-left:auto;font-size:11px;padding:3px 10px}
.cand{padding:10px 14px;border-bottom:1px solid var(--line);cursor:pointer;position:relative}
.cand:hover{background:var(--cream)}
.cand .who{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:76px}
.cand .subj{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.cand .meta{font-size:11px;color:var(--muted);margin-top:3px}
.cand-draft{position:absolute;top:10px;right:12px;font-size:10px;font-weight:700;padding:4px 9px;
border-radius:5px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer}
.cand-draft:hover{background:#233b31}
.subbar{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--line);
background:var(--paper);flex-shrink:0}
.subbar label{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700}
.subbar select{font:inherit;font-size:12px;border:1px solid var(--line);border-radius:5px;
padding:4px 7px;background:var(--cream);color:var(--ink);flex:1}
.panel.collapsed .subbar{display:none}
.draft-del{position:absolute;bottom:8px;right:10px;width:22px;height:22px;border-radius:5px;
border:1px solid var(--line);background:var(--paper);color:var(--muted);font-size:11px;
line-height:1;cursor:pointer;padding:0;display:none}
.draft:hover .draft-del{display:block}
.draft-del:hover{border-color:var(--red);color:var(--red);background:var(--redbg)}
.badge{font-size:10px;font-weight:700;letter-spacing:.5px;border-radius:4px;padding:2px 6px;text-transform:uppercase}
.r-high{background:var(--redbg);color:var(--red)}
.r-elevated{background:var(--amberbg);color:var(--amber)}
.r-caution{background:var(--slatebg);color:var(--slate)}
.r-clear{background:var(--greenbg);color:var(--green)}
.r-pending{background:#ece8df;color:var(--muted)}
.spin{display:inline-block;width:11px;height:11px;border:2px solid var(--line);
border-top-color:var(--gold);border-radius:50%;animation:sp .7s linear infinite;vertical-align:-1px}
@keyframes sp{to{transform:rotate(360deg)}}
/* ── correspondence ── */
.empty{padding:48px 28px;text-align:center;color:var(--muted);font-size:13px}
.empty .big{font-size:34px;margin-bottom:10px;opacity:.5}
.risk-banner{margin:14px;border-radius:8px;padding:13px 15px;border:1px solid}
.risk-banner.high{background:var(--redbg);border-color:#d8b4ae}
.risk-banner.elevated{background:var(--amberbg);border-color:#dcc99a}
.risk-banner.caution{background:var(--slatebg);border-color:#c7d1d8}
.risk-banner.clear{background:var(--greenbg);border-color:#b6d3c0}
.risk-banner h3{font-size:14px;display:flex;align-items:center;gap:8px;font-family:inherit}
.risk-banner p{font-size:12.5px;margin-top:5px;color:var(--ink)}
.risk-banner .sigs{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
.contact-hd{padding:4px 16px 8px;font-size:12px;color:var(--muted)}
.contact-hd b{color:var(--ink);font-size:13px}
.thread{margin:0 14px 12px;border:1px solid var(--line);border-radius:8px;background:var(--paper);overflow:hidden}
.thread-hd{padding:8px 12px;background:var(--cream);cursor:pointer;display:flex;align-items:center;gap:8px}
.thread-hd .t-subj{font-weight:600;font-size:12.5px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.thread-hd .t-meta{font-size:11px;color:var(--muted);white-space:nowrap}
.msg{padding:9px 12px;border-top:1px solid var(--line);cursor:pointer}
.msg:hover{background:var(--cream)}
.msg .m-top{display:flex;gap:8px;align-items:baseline}
.msg .dir{font-size:10px;font-weight:700;border-radius:3px;padding:1px 5px}
.dir.in{background:var(--slatebg);color:var(--slate)}
.dir.out{background:#ede7d8;color:var(--gold)}
.msg .m-from{font-size:12px;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .m-date{font-size:11px;color:var(--muted)}
.msg .m-snip{font-size:12px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .m-body{font-size:12.5px;white-space:pre-wrap;margin-top:7px;padding:9px;background:var(--cream);
border-radius:6px;border:1px solid var(--line);max-height:300px;overflow-y:auto;display:none}
.msg.open .m-body{display:block}
.msg.open .m-snip{display:none}
.sig-chip{font-size:9px;font-weight:700;letter-spacing:.4px;background:var(--redbg);color:var(--red);
border-radius:3px;padding:1px 5px}
/* warm-lead signals (invoice/samples WE sent) — these mean "follow up", not "stop" */
.warm-chip{font-size:9px;font-weight:700;letter-spacing:.4px;background:var(--greenbg);color:var(--green);
border-radius:3px;padding:1px 5px}
.cand.warm{box-shadow:inset 3px 0 0 var(--green)}
.cand .warms{margin-top:4px;display:flex;gap:5px;flex-wrap:wrap}
/* ── right / pitch ── */
.pitch-empty{padding:48px 24px;text-align:center;color:var(--muted);font-size:13px}
.pitch{display:flex;flex-direction:column;height:100%}
.pitch-scroll{flex:1;overflow-y:auto;padding:14px}
.fld{margin-bottom:11px}
.fld label{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-weight:700;margin-bottom:3px}
.fld input,.fld textarea{width:100%;font:inherit;border:1px solid var(--line);border-radius:6px;
padding:7px 9px;background:var(--paper);color:var(--ink)}
.fld textarea{min-height:230px;resize:vertical;font-size:13px;line-height:1.55}
.fld .ro{background:var(--cream);padding:7px 9px;border:1px solid var(--line);border-radius:6px;font-size:13px}
.verdict{border-radius:8px;padding:11px 13px;margin-bottom:12px;border:1px solid}
.verdict.high{background:var(--redbg);border-color:#d8b4ae}
.verdict.elevated{background:var(--amberbg);border-color:#dcc99a}
.verdict.caution{background:var(--slatebg);border-color:#c7d1d8}
.verdict.clear{background:var(--greenbg);border-color:#b6d3c0}
.verdict h3{font-size:13px;font-family:inherit}
.verdict p{font-size:12px;margin-top:4px}
/* ── Already-Sent panel ── */
.sent-alert{margin:10px 12px;padding:10px 12px;background:var(--redbg);border:1px solid #d8b4ae;
border-left:4px solid var(--red);border-radius:7px;color:var(--red)}
.sent-alert h3{font-size:13px;font-weight:700;display:flex;align-items:center;gap:7px}
.sent-alert .when{margin-top:4px;font-size:12px;font-weight:600}
.sent-alert p{font-size:11.5px;margin-top:5px;color:#7a2a23}
.sent-list{padding:4px 12px 12px}
.sent-item{border:1px solid var(--line);border-radius:7px;padding:8px 10px;margin-bottom:8px;background:var(--paper)}
.sent-item.fu{border-color:#d8b4ae;background:var(--redbg)}
.sent-item .si-subj{font-size:12.5px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sent-item .si-when{font-size:11px;color:var(--muted);margin-top:3px;display:flex;align-items:center;gap:6px}
.sent-item.fu .si-when{color:var(--red);font-weight:600}
.sent-item .si-snip{font-size:11px;color:#6b6456;margin-top:5px;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.sent-item .si-tag{font-size:9px;font-weight:700;letter-spacing:.4px;background:var(--red);color:#fff;
border-radius:4px;padding:1px 6px;text-transform:uppercase}
.sent-item a.si-page{font-size:11px;color:var(--gold);text-decoration:none;margin-top:5px;display:inline-block}
.override{margin-top:9px;font-size:12px;display:flex;gap:7px;align-items:flex-start}
.override input{margin-top:2px}
.pitch-foot{padding:11px 14px;border-top:1px solid var(--line);background:var(--paper);
display:flex;gap:8px;align-items:center;flex-shrink:0}
.pitch-foot .spacer{flex:1}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--ink);
color:var(--cream);padding:10px 18px;border-radius:8px;font-size:13px;opacity:0;
transition:.25s;pointer-events:none;z-index:50}
.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>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header>
<a class="brand" href="https://designerwallcoverings.com" title="Designer Wallcoverings">
<svg width="40" height="40" viewBox="0 0 48 48" aria-label="Designer Wallcoverings">
<circle cx="24" cy="24" r="22" fill="none" stroke="#9a7b3f" stroke-width="1.5"/>
<text x="24" y="31" font-family="Georgia,'Times New Roman',serif" font-size="19" font-weight="600" fill="#f6f3ec" text-anchor="middle" letter-spacing="0.5">DW</text>
</svg>
<span class="wm"><b>DESIGNER</b><i>WALLCOVERINGS</i></span>
</a>
<span class="divider"></span>
<h1>Pitch Guard</h1>
<span class="sub">review before you follow up</span>
<span class="spacer"></span>
<button class="reset" id="resetLayout" title="Reset panels to default layout">⤢ reset layout</button>
<span class="acct" id="acct">info@designerwallcoverings.com</span>
</header>
<div class="layout" id="layout">
<!-- DRAFTS -->
<div class="panel" id="panel-drafts" data-panel="drafts">
<div class="pane-hd">
<span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Drafts</span></span>
<span class="grip">⠿</span>
<h2>Drafts</h2><span class="count" id="draftCount">·</span>
<span class="spacer"></span>
<button class="btn-sm" id="scanAll" title="Scan every contact for prior orders">Scan risk</button>
<button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
<button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="subbar">
<label>Sort</label>
<select id="sortSel">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="invoice">Invoice sent first</option>
<option value="samples">Samples sent first</option>
<option value="name">Recipient name A→Z</option>
<option value="subject">Subject A→Z</option>
<option value="risk">Risk (high → clear)</option>
</select>
</div>
<div class="pane-body" id="draftList">
<div class="empty"><div class="spin"></div> loading drafts…</div>
</div>
</div>
<!-- CORRESPONDENCE -->
<div class="panel flexed" id="panel-corr" data-panel="corr">
<div class="pane-hd">
<span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Correspondence</span></span>
<span class="grip">⠿</span>
<h2>Correspondence</h2>
<span class="count" id="threadCount" style="display:none"></span>
<span class="spacer"></span>
<button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
<button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="pane-body" id="corr">
<div class="empty"><div class="big">✉</div>Pick a draft to see every thread<br>with that contact before you decide.</div>
</div>
</div>
<!-- PITCH -->
<div class="panel" id="panel-pitch" data-panel="pitch">
<div class="pane-hd">
<span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">The Pitch</span></span>
<span class="grip">⠿</span>
<h2>The Pitch</h2>
<span class="spacer"></span>
<button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
<button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="pane-body" id="pitch">
<div class="pitch-empty">No draft selected.<br>The followup waiting for this client will appear here.</div>
</div>
</div>
<!-- ALREADY SENT -->
<div class="panel" id="panel-sent" data-panel="sent">
<div class="pane-hd">
<span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Already Sent</span></span>
<span class="grip">⠿</span>
<h2>Already Sent</h2><span class="count" id="sentCount" style="display:none"></span>
<span class="spacer"></span>
<button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
<button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="pane-body" id="sentBody">
<div class="empty">Pick a draft to see followups already sent to that contact.</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = s => document.querySelector(s);
let DRAFTS = [], CURRENT = null, MORE = false;
let vendorsOpen = localStorage.getItem('pg.vendorsOpen') === '1';
let sortMode = localStorage.getItem('pg.sort') || 'newest';
let CANDIDATES = [], candScanned = false, candScanning = false, candScannedAt = null;
let candOpen = localStorage.getItem('pg.candOpen') !== '0';
const riskCache = {}; // email -> full /api/contact result
const liteCache = {}; // email -> light /api/risk result
const verdictOf = email => riskCache[email] || liteCache[email] || null;
function toast(msg, err){ const t=$('#toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2600); }
function esc(s){ return String(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function fmtDate(d){ if(!d) return ''; const t=Date.parse(d); if(!t) return d; const dt=new Date(t);
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'}); }
function fmtDateTime(d){ if(!d) return ''; const t=Date.parse(d); if(!t) return d; const dt=new Date(t);
return dt.toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit'}); }
const riskWord = {high:'High risk',elevated:'Elevated',caution:'Caution',clear:'Clear'};
/* ════════ LAYOUT MANAGER — resize · collapse · reorder · pop-out ════════ */
const PANELS = ['drafts','corr','pitch','sent'];
const LS = 'pg.layout.v2';
const DEFAULT_LAYOUT = { order:['drafts','corr','pitch','sent'], width:{drafts:336,corr:0,pitch:438,sent:308}, collapsed:{} };
let layout = loadLayout();
function loadLayout(){
try{ const v=JSON.parse(localStorage.getItem(LS)); if(v&&v.order) return Object.assign({},DEFAULT_LAYOUT,v,{width:Object.assign({},DEFAULT_LAYOUT.width,v.width),collapsed:v.collapsed||{}}); }catch(e){}
return JSON.parse(JSON.stringify(DEFAULT_LAYOUT));
}
function saveLayout(){ try{ localStorage.setItem(LS,JSON.stringify(layout)); }catch(e){} }
// apply order + widths + collapsed to the DOM
function applyLayout(){
const lay = $('#layout');
// strip existing handles
lay.querySelectorAll('.rz-handle').forEach(h=>h.remove());
// order the panels
layout.order.forEach(key=>{
const p = document.getElementById('panel-'+key);
if(p) lay.appendChild(p);
});
// the middle slot flexes; edges are fixed-width
layout.order.forEach((key,i)=>{
const p = document.getElementById('panel-'+key);
if(!p) return;
p.classList.toggle('collapsed', !!layout.collapsed[key]);
const mid = (i===1);
p.classList.toggle('flexed', mid && !layout.collapsed[key]);
if(!mid && !layout.collapsed[key]){
p.style.flex = '0 0 ' + (layout.width[key]||340) + 'px';
} else {
p.style.flex = '';
}
});
// insert resize handles between panels
for(let i=0;i<layout.order.length-1;i++){
const after = document.getElementById('panel-'+layout.order[i]);
const h = document.createElement('div');
h.className='rz-handle'; h.dataset.idx=i;
after.after(h);
wireHandle(h, i);
}
saveLayout();
}
// resize: handle i sits between slot i and slot i+1; drag resizes the non-middle one
function wireHandle(h, i){
h.addEventListener('pointerdown', e=>{
e.preventDefault();
const leftKey = layout.order[i], rightKey = layout.order[i+1];
// pick the edge (non-middle) panel this handle should resize
const targetKey = (i===0) ? leftKey : rightKey;
const grow = (i===0) ? 1 : -1; // dragging right widens slot0; widens slot2 when dragging left
const startX = e.clientX, startW = layout.width[targetKey] || document.getElementById('panel-'+targetKey).offsetWidth;
h.classList.add('active');
h.setPointerCapture(e.pointerId);
const move = ev=>{
let w = startW + grow*(ev.clientX-startX);
w = Math.max(180, Math.min(900, w));
layout.width[targetKey] = w;
const p = document.getElementById('panel-'+targetKey);
if(p) p.style.flex = '0 0 '+w+'px';
};
const up = ()=>{ h.classList.remove('active'); h.removeEventListener('pointermove',move);
h.removeEventListener('pointerup',up); saveLayout(); };
h.addEventListener('pointermove',move);
h.addEventListener('pointerup',up);
});
}
// reorder: drag a panel by its header onto another panel
let dragKey = null;
function wireDragReorder(){
PANELS.forEach(key=>{
const p = document.getElementById('panel-'+key);
const hd = p.querySelector('.pane-hd');
hd.setAttribute('draggable','true');
hd.addEventListener('dragstart', e=>{
dragKey = key; p.classList.add('dragging');
e.dataTransfer.effectAllowed='move';
try{ e.dataTransfer.setData('text/plain',key); }catch(_){}
});
hd.addEventListener('dragend', ()=>{ p.classList.remove('dragging');
PANELS.forEach(k=>document.getElementById('panel-'+k).classList.remove('drop-target')); dragKey=null; });
p.addEventListener('dragover', e=>{ if(dragKey&&dragKey!==key){ e.preventDefault(); p.classList.add('drop-target'); } });
p.addEventListener('dragleave', ()=>p.classList.remove('drop-target'));
p.addEventListener('drop', e=>{
e.preventDefault(); p.classList.remove('drop-target');
if(!dragKey||dragKey===key) return;
const ord = layout.order.slice();
ord.splice(ord.indexOf(dragKey),1);
ord.splice(ord.indexOf(key),0,dragKey);
layout.order = ord; applyLayout();
});
});
}
function toggleCollapse(key){
layout.collapsed[key] = !layout.collapsed[key];
applyLayout();
}
function popout(key){
const u = new URL(location.href);
u.searchParams.set('only', key);
if(CURRENT) u.searchParams.set('draft', CURRENT);
window.open(u.toString(), 'pg-'+key, 'width=560,height=900');
}
function resetLayout(){ localStorage.removeItem(LS); layout = JSON.parse(JSON.stringify(DEFAULT_LAYOUT)); applyLayout(); toast('Layout reset'); }
// header buttons (collapse / popout) + collapsed-strip click
function wirePanelControls(){
PANELS.forEach(key=>{
const p = document.getElementById('panel-'+key);
if(!p) return; // removed in solo mode
p.querySelectorAll('[data-act]').forEach(btn=>{
btn.addEventListener('click', e=>{
e.stopPropagation();
if(btn.dataset.act==='collapse') toggleCollapse(key);
else if(btn.dataset.act==='popout') popout(key);
});
});
// clicking anywhere on a collapsed panel re-expands it
p.querySelector('.pane-hd').addEventListener('click', e=>{
if(p.classList.contains('collapsed') && !e.target.closest('[data-act]')) toggleCollapse(key);
});
});
}
// solo mode — a popped-out single panel
function applySoloMode(){
const params = new URLSearchParams(location.search);
const only = params.get('only');
if(!only || !PANELS.includes(only)) return null;
document.body.classList.add('solo');
PANELS.forEach(k=>{
const p = document.getElementById('panel-'+k);
if(k===only){ p.classList.add('flexed'); p.classList.remove('collapsed'); p.style.flex='1 1 0'; }
else p.remove();
});
$('#resetLayout').style.display='none';
document.querySelector('header .sub').textContent = only==='drafts'?'drafts':only==='corr'?'correspondence':'the pitch';
return params.get('draft');
}
/* ════════ DATA ════════ */
async function loadDrafts(){
try{
const r = await fetch('/api/drafts'); const d = await r.json();
if(d.error) throw new Error(d.error);
DRAFTS = d.drafts; MORE = d.more;
const acct = $('#acct'); if(acct) acct.textContent = d.account;
const dc = $('#draftCount'); if(dc) dc.textContent = d.count + (d.more ? '+' : '');
renderDrafts();
}catch(e){ const el = $('#draftList'); if(el) el.innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
}
const RISK_RANK = {high:0,elevated:1,caution:2,clear:3};
function sortDrafts(arr){
const a = arr.slice(), ts = d => Date.parse(d.date)||0;
const nm = d => (d.toName||'').toLowerCase();
if(sortMode==='oldest') a.sort((x,y)=>ts(x)-ts(y));
else if(sortMode==='name') a.sort((x,y)=>nm(x).localeCompare(nm(y)));
else if(sortMode==='subject') a.sort((x,y)=>(x.subject||'').toLowerCase().localeCompare((y.subject||'').toLowerCase()));
else if(sortMode==='risk') a.sort((x,y)=>{
const rx=RISK_RANK[(verdictOf(x.toEmail)||{}).risk]??9, ry=RISK_RANK[(verdictOf(y.toEmail)||{}).risk]??9;
return rx-ry || ts(y)-ts(x);
});
else a.sort((x,y)=>ts(y)-ts(x)); // newest
return a;
}
// Followup candidates carry warm signals (INVOICE SENT / SAMPLES SENT we sent). Default
// order = warm leads first, then longest-quiet. The 'invoice'/'samples' sort options
// pull that signal to the very top — the "find who needs a follow" criteria Steve asked for.
function sortCandidates(arr){
const a = arr.slice();
const hasInv = c => (c.warmSignals||[]).includes('INVOICE SENT') ? 1 : 0;
const hasSam = c => (c.warmSignals||[]).includes('SAMPLES SENT') ? 1 : 0;
const warmN = c => (c.warmSignals||[]).length ? 1 : 0;
if(sortMode==='invoice') a.sort((x,y)=> hasInv(y)-hasInv(x) || (y.sentInvoiceTs||0)-(x.sentInvoiceTs||0) || y.daysSince-x.daysSince);
else if(sortMode==='samples') a.sort((x,y)=> hasSam(y)-hasSam(x) || (y.sentSamplesTs||0)-(x.sentSamplesTs||0) || y.daysSince-x.daysSince);
else a.sort((x,y)=> warmN(y)-warmN(x) || y.daysSince-x.daysSince); // warm-first, then longest-quiet
return a;
}
function draftChip(d){
const c = verdictOf(d.toEmail);
let badge = '<span class="badge r-pending">·</span>';
if(c) badge = '<span class="badge r-'+c.risk+'">'+c.risk+'</span>';
return '<div class="draft'+(CURRENT===d.id?' active':'')+'" data-id="'+d.id+'">'+
badge+
'<div class="who">'+esc(d.toName)+'</div>'+
'<div class="subj">'+esc(d.subject)+'</div>'+
'<div class="meta">'+esc(d.toEmail||'no recipient')+' · '+fmtDate(d.date)+'</div>'+
'<button class="draft-del" data-del="'+d.id+'" title="Move draft to Trash">✕</button>'+
'</div>';
}
function candidateSectionHTML(){
let body;
if(candScanning) body='<div class="empty" style="padding:24px"><div class="spin"></div> scanning sent mail for ghosted quotes…</div>';
else if(!candScanned) body='<div class="vgroup-note">Not scanned yet — click <b>Scan</b> to find quote/pricing threads we replied to that got no client response in 5+ days and have no followup drafted.</div>';
else if(!CANDIDATES.length) body='<div class="vgroup-note">No followup candidates — every ghosted quote in range already has a draft.'+(candScannedAt?' <span style="opacity:.7">· auto-scanned '+fmtDate(candScannedAt)+'</span>':'')+'</div>';
else body=(candScannedAt?'<div class="vgroup-note">Auto-scans daily · last scan '+fmtDate(candScannedAt)+' — review, then ✎ Draft it. <b>Green chips</b> = invoice/samples we sent (top follow-up priority).</div>':'')+sortCandidates(CANDIDATES).map(c=>{
const warms=(c.warmSignals||[]).map(w=>'<span class="warm-chip">'+esc(w)+'</span>').join(' ');
return '<div class="cand'+((c.warmSignals||[]).length?' warm':'')+'" data-email="'+esc(c.contact)+'">'+
'<button class="cand-draft" data-draft-cand="'+esc(c.contact)+'" title="Create the followup draft">✎ Draft it</button>'+
'<div class="who">'+esc(c.contactName)+(c.uncertain?' <span title="Reply/draft check was throttled — shown so it is not silently dropped; verify the thread before sending" style="color:#b8860b;font-size:11px">⚠ verify</span>':'')+'</div>'+
'<div class="subj">'+esc(c.subject)+'</div>'+
'<div class="meta">'+esc(c.contact)+' · '+c.daysSince+'d no reply</div>'+
(warms?'<div class="warms">'+warms+'</div>':'')+
'</div>';}).join('');
return '<div class="cgroup-hd'+(candOpen?'':' closed')+'" id="cgroupHd" title="Quotes with no client reply in 5+ days — the 5-day followup rule">'+
'<span class="arrow">▼</span> Followup Candidates '+
'<span class="vct">'+(candScanned?CANDIDATES.length:'—')+'</span>'+
'<button class="btn-sm scan-btn" id="scanCandBtn">'+(candScanned?'Rescan':'Scan')+'</button>'+
'</div>'+
'<div id="cgroupBody" style="display:'+(candOpen?'block':'none')+'">'+body+'</div>';
}
function renderDrafts(){
const el = $('#draftList');
if(!el) return; // drafts panel popped out / not in this window
const clients = sortDrafts(DRAFTS.filter(d=>d.kind!=='vendor'));
const vendors = sortDrafts(DRAFTS.filter(d=>d.kind==='vendor'));
let html = candidateSectionHTML();
if(!DRAFTS.length){
html += '<div class="empty">No drafts loaded.</div>';
} else {
if(MORE) html+='<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>';
html += clients.length ? clients.map(draftChip).join('') : '<div class="empty">No client drafts in this batch.</div>';
if(vendors.length){
html += '<div class="vgroup-hd'+(vendorsOpen?'':' closed')+'" id="vgroupHd" title="Vendor / supplier drafts — not clients to pitch">'+
'<span class="arrow">▼</span> Vendors <span class="vct">'+vendors.length+'</span></div>'+
'<div id="vgroupBody" style="display:'+(vendorsOpen?'block':'none')+'">'+
'<div class="vgroup-note">Drafts addressed to suppliers / manufacturers — not clients. Review, don’t pitch.</div>'+
vendors.map(draftChip).join('')+
'</div>';
}
}
el.innerHTML = html;
el.querySelectorAll('.draft').forEach(n=>n.onclick=()=>selectDraft(n.dataset.id));
el.querySelectorAll('.draft-del').forEach(b=>b.onclick=e=>{ e.stopPropagation(); deleteDraft(b.dataset.del); });
el.querySelectorAll('.cand').forEach(n=>n.onclick=ev=>{ if(ev.target.closest('[data-draft-cand]'))return; loadContact(n.dataset.email); });
el.querySelectorAll('[data-draft-cand]').forEach(b=>b.onclick=ev=>{ ev.stopPropagation(); createFollowup(b.dataset.draftCand); });
const ch=$('#cgroupHd');
if(ch) ch.onclick=ev=>{
if(ev.target.closest('#scanCandBtn')) return;
candOpen=!candOpen; localStorage.setItem('pg.candOpen',candOpen?'1':'0');
$('#cgroupBody').style.display=candOpen?'block':'none'; ch.classList.toggle('closed',!candOpen);
};
const scb=$('#scanCandBtn'); if(scb) scb.onclick=ev=>{ ev.stopPropagation(); scanCandidates(); };
const vh = $('#vgroupHd');
if(vh) vh.onclick=()=>{
vendorsOpen=!vendorsOpen;
localStorage.setItem('pg.vendorsOpen', vendorsOpen?'1':'0');
$('#vgroupBody').style.display = vendorsOpen?'block':'none';
vh.classList.toggle('closed', !vendorsOpen);
};
const dc=$('#draftCount'); if(dc) dc.textContent = clients.length + (MORE?'+':'');
}
async function scanCandidates(){
if(candScanning) return;
candScanning=true; renderDrafts();
try{
const r=await fetch('/api/followup-candidates'+(candScanned?'?fresh=1':''));
const d=await r.json();
if(d.error) throw new Error(d.error);
CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||new Date().toISOString();
toast('Scan done — '+CANDIDATES.length+' followup candidate'+(CANDIDATES.length===1?'':'s'));
}catch(e){ toast('Scan failed: '+e.message,1); }
candScanning=false; renderDrafts();
}
// quiet auto-load on page open — the server keeps the candidate list warm
async function autoLoadCandidates(){
try{
const r=await fetch('/api/followup-candidates');
const d=await r.json();
if(d.error) return;
CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||null;
renderDrafts();
}catch(e){}
}
async function createFollowup(email){
const c=CANDIDATES.find(x=>x.contact===email); if(!c) return;
if(!confirm('Create a followup draft for:\n\n'+c.contactName+' <'+c.contact+'>\n"'+c.subject+'" ('+c.daysSince+'d no reply)\n\nIt lands as a draft in info@ — nothing is sent until you send it.')) return;
try{
const r=await fetch('/api/followup/create',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({to:c.contact,subject:c.subject,threadId:c.threadId})});
const out=await r.json();
if(out.error) throw new Error(out.error);
CANDIDATES=CANDIDATES.filter(x=>x.contact!==email);
renderDrafts();
toast('Followup draft created for '+c.contact);
}catch(e){ toast('Draft failed: '+e.message,1); }
}
async function deleteDraft(id){
const d = DRAFTS.find(x=>x.id===id); if(!d) return;
if(!confirm('Move this draft to Trash?\n\nTo: '+(d.toEmail||d.toName||'(no recipient)')+'\n'+(d.subject||'')+
'\n\nIt stays recoverable in Gmail Trash for 30 days.')) return;
try{
const r = await fetch('/api/draft/'+encodeURIComponent(id)+'/trash',{method:'POST'});
const out = await r.json();
if(out.error) throw new Error(out.error);
DRAFTS = DRAFTS.filter(x=>x.id!==id);
if(CURRENT===id){
CURRENT=null;
const corr=$('#corr'); if(corr) corr.innerHTML='<div class="empty"><div class="big">✉</div>Pick a draft to see every thread<br>with that contact before you decide.</div>';
const pit=$('#pitch'); if(pit) pit.innerHTML='<div class="pitch-empty">No draft selected.<br>The followup waiting for this client will appear here.</div>';
}
renderDrafts();
toast('Draft moved to Trash');
}catch(e){ toast('Delete failed: '+e.message,1); }
}
async function selectDraft(id){
CURRENT = id; renderDrafts();
const d = DRAFTS.find(x=>x.id===id); if(!d) return;
const corr = $('#corr');
if(corr){
if(!d.toEmail) corr.innerHTML = '<div class="empty">This draft has no recipient address — nothing to cross-check.</div>';
else { corr.innerHTML = '<div class="empty"><div class="spin"></div> pulling every thread with '+esc(d.toEmail)+'…</div>'; loadContact(d.toEmail); }
} else if(d.toEmail){ loadContact(d.toEmail); }
const sb=$('#sentBody');
if(sb){ sb.innerHTML = d.toEmail ? '<div class="empty"><div class="spin"></div> checking followups sent to '+esc(d.toEmail)+'…</div>' : '<div class="empty">This draft has no recipient address.</div>'; }
loadPitch(d);
}
async function loadContact(email){
try{
let c = riskCache[email];
if(!c){ const r=await fetch('/api/contact?email='+encodeURIComponent(email)); c=await r.json();
if(c.error) throw new Error(c.error); riskCache[email]=c; renderDrafts(); }
renderCorr(c); renderVerdict(c); renderSent(c);
}catch(e){ if($('#corr')) $('#corr').innerHTML='<div class="empty">⚠ '+esc(e.message)+'</div>';
if($('#sentBody')) $('#sentBody').innerHTML='<div class="empty">⚠ '+esc(e.message)+'</div>'; }
}
// ── 4th column: followups/letters already sent to this contact ──
function renderSent(c){
const body = $('#sentBody'); if(!body) return; // panel not in this window
const cnt = $('#sentCount');
const letters = (c.sentLetters||[]).slice().sort((a,b)=>(b.ts||0)-(a.ts||0));
const fu = c.recentFollowup; // the most-recent matched followup (last 21d)
if(cnt){ cnt.style.display='inline-block'; cnt.textContent=letters.length; }
let html='';
if(fu){
const days=fu.daysAgo;
html += '<div class="sent-alert"><h3>⚠ Followup already sent</h3>'+
'<div class="when">'+esc(fmtDateTime(fu.date))+' · '+(days===0?'today':days+' day'+(days===1?'':'s')+' ago')+'</div>'+
'<p>A standard followup already went out to this contact. Sending another now reads as spam — hold off unless you have a reason.</p></div>';
}
if(!letters.length){
html += '<div class="empty">'+(fu?'':'✓ ')+'No prior letters found to '+esc(c.email||'this contact')+(fu?'':' — clear to follow up.')+'</div>';
} else {
html += '<div class="sent-list">';
for(const m of letters){
const isFu = fu && Math.abs((m.ts||0)-(fu.ts||0))<60000; // same message as the matched followup
const page = (m.id && !String(m.id).startsWith('ledger:')) ? '<a class="si-page" href="/api/draft/'+encodeURIComponent(m.id)+'/html" target="_blank" rel="noopener noreferrer">↗ View letter</a>' : '';
html += '<div class="sent-item'+(isFu?' fu':'')+'">'+
'<div class="si-subj">'+(isFu?'<span class="si-tag">followup</span> ':'')+esc(m.subject||'(no subject)')+'</div>'+
'<div class="si-when">🕓 '+esc(fmtDateTime(m.date))+'</div>'+
(m.snippet?'<div class="si-snip">'+esc(m.snippet)+'</div>':'')+
page+'</div>';
}
html += '</div>';
}
body.innerHTML = html;
}
function renderCorr(c){
if(!$('#corr')) return; // correspondence panel not in this window
$('#threadCount').style.display='inline-block';
$('#threadCount').textContent = c.threadCount+' thread'+(c.threadCount===1?'':'s');
const sigChips = c.signals.map(s=>'<span class="badge r-high">'+esc(s)+'</span>').join(' ');
let banner='';
if(c.risk==='high') banner='<div class="risk-banner high"><h3>⛔ Already a customer — do NOT pitch</h3>'+
'<p>This contact has thread activity that looks like a completed order. Sending a generic followup here is exactly what upsets Samantha. Verify with her before anything goes out.</p>'+
(sigChips?'<div class="sigs">'+sigChips+'</div>':'')+'</div>';
else if(c.risk==='elevated') banner='<div class="risk-banner elevated"><h3>⚠ Likely transacting</h3>'+
'<p>Invoice / order signals found across this contact’s threads. Read them before deciding to follow up.</p>'+
(sigChips?'<div class="sigs">'+sigChips+'</div>':'')+'</div>';
else if(c.risk==='caution') banner='<div class="risk-banner caution"><h3>↔ Multiple threads</h3>'+
'<p>More than one thread with this contact. Make sure the followup is not duplicating one already handled.</p></div>';
else banner='<div class="risk-banner clear"><h3>✓ Clear to follow up</h3>'+
'<p>No invoice / payment / shipping signals found in this contact’s history.</p></div>';
if(c.recentFollowup){
const fw=c.recentFollowup.daysAgo;
if(c.risk==='clear'||c.risk==='caution') banner=banner.replace(/risk-banner (clear|caution)/,'risk-banner elevated');
banner=banner.replace(/<\/div>\s*$/,'')+
'<p style="margin-top:8px;font-weight:600;color:var(--amber);">⚠ Followup already sent '+
(fw===0?'today':fw+' day'+(fw===1?'':'s')+' ago')+
' — a second one this soon reads as spam. Hold off.</p></div>';
}
const threads = c.threads.map(t=>{
const tsig = t.signals.length? ' '+t.signals.map(s=>'<span class="sig-chip">'+esc(s)+'</span>').join('') : '';
const msgs = t.messages.map(m=>{
const ms = m.signals.length? ' '+m.signals.map(s=>'<span class="sig-chip">'+esc(s)+'</span>').join('') : '';
return '<div class="msg" data-mid="'+m.id+'">'+
'<div class="m-top"><span class="dir '+(m.inbound?'in':'out')+'">'+(m.inbound?'IN':'OUT')+'</span>'+
'<span class="m-from">'+esc(m.inbound?(m.fromName||m.from):'Designer Wallcoverings')+'</span>'+
'<span class="m-date">'+fmtDate(m.date)+'</span></div>'+
'<div class="m-snip">'+esc(m.snippet)+ms+'</div>'+
'<div class="m-body" data-loaded="0">'+ms+'</div>'+
'</div>';
}).join('');
return '<div class="thread"><div class="thread-hd"><span class="t-subj">'+esc(t.subject)+tsig+'</span>'+
'<span class="t-meta">'+t.messages.length+' msg · '+fmtDate(t.lastDate)+'</span></div>'+msgs+'</div>';
}).join('');
// Letters we've sent to this contact — dates + expandable full bodies.
let sentBlock='';
const sl = c.sentLetters||[];
if(sl.length){
const rows = sl.map(m=>
'<div class="msg sent-letter" data-mid="'+esc(m.id)+'"'+(m.fromLedger?' data-ledger="1"':'')+'>'+
'<div class="m-top"><span class="dir out">SENT</span>'+
'<span class="m-from">'+esc(m.subject)+'</span>'+
'<span class="m-date">'+fmtDate(m.date)+'</span></div>'+
'<div class="m-snip">'+esc(m.snippet||'')+'</div>'+
'<div class="m-body" data-loaded="0"'+(m.fromLedger?(' data-ledgerbody="'+esc(m.ledgerBody||'')+'"'):'')+'></div>'+
'</div>').join('');
sentBlock='<div class="thread sent-letters-wrap"><div class="thread-hd">'+
'<span class="t-subj">✉ Letters we’ve sent to '+esc(c.email)+'</span>'+
'<span class="t-meta">'+sl.length+' sent</span></div>'+rows+'</div>';
}
$('#corr').innerHTML = banner +
'<div class="contact-hd"><b>'+esc(c.email)+'</b> — '+c.totalMessages+' messages across '+c.threadCount+' thread'+(c.threadCount===1?'':'s')+
(c.customerReplied?' · customer has replied':'')+(sl.length?' · '+sl.length+' letter'+(sl.length===1?'':'s')+' sent':'')+'</div>' + sentBlock + threads;
$('#corr').querySelectorAll('.msg').forEach(n=>n.onclick=()=>toggleMsg(n));
}
async function toggleMsg(node){
node.classList.toggle('open');
const body = node.querySelector('.m-body');
if(node.classList.contains('open') && body.dataset.loaded==='0'){
body.dataset.loaded='1';
// ledger letters carry their full body inline — no fetch needed
if(node.dataset.ledger==='1'){
const lb=body.dataset.ledgerbody||'';
body.innerHTML = renderEmailHTML(stripBanner(lb)) || '(no text body)';
return;
}
body.textContent='loading…';
try{ const r=await fetch('/api/message/'+node.dataset.mid); const m=await r.json();
body.textContent = m.body || m.snippet || '(no text body)';
}catch(e){ body.textContent='⚠ '+e.message; }
}
}
// 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=stripBanner(m.body||''); }
catch(e){ body='(could not load draft body: '+e.message+')'; }
el.innerHTML =
'<div class="pitch">'+
'<div class="pitch-scroll">'+
'<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">'+
'<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 noreferrer" 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>'+
'<button id="delBtn" class="btn-sm" style="color:var(--red);border-color:#d8b4ae" title="Move this draft to Trash">🗑 Delete draft</button>'+
'<button id="skipBtn" class="btn-sm">Skip</button>'+
'<button id="sendBtn" class="btn-go">Send followup</button>'+
'</div>'+
'</div>';
$('#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>';
else $('#verdictSlot').innerHTML='<div class="verdict caution"><h3>No recipient on this draft</h3></div>';
}
function renderVerdict(c){
const slot = $('#verdictSlot'); if(!slot) return;
let html='', gated = (c.risk==='high'||c.risk==='elevated');
if(c.risk==='high') html='<h3>⛔ '+riskWord.high+'</h3><p>Order / payment activity found. This client should likely <b>not</b> get a followup pitch. Confirm with Samantha first.</p>';
else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the correspondence before sending.</p>';
else if(c.risk==='caution') html='<h3>↔ '+riskWord.caution+'</h3><p>Multiple threads exist. Make sure this followup is not a duplicate.</p>';
else html='<h3>✓ '+riskWord.clear+'</h3><p>No prior-order signals. Safe to follow up.</p>';
let boxClass = c.risk;
if(c.recentFollowup){
const fw=c.recentFollowup.daysAgo;
html+='<p style="margin-top:8px;padding:8px 10px;background:var(--redbg);border:1px solid #d8b4ae;'+
'border-left:4px solid var(--red);border-radius:6px;'+
'color:var(--red);font-weight:700;font-size:12px;">⚠ A standard followup was already sent to '+
'this contact on '+esc(fmtDateTime(c.recentFollowup.date))+' ('+(fw===0?'today':fw+' day'+(fw===1?'':'s')+' ago')+
'). Sending another now reads as spam — hold off. See <b>Already Sent →</b></p>';
if(c.risk==='clear'||c.risk==='caution') boxClass='elevated';
}
if(gated) html+='<label class="override"><input type="checkbox" id="ovr">'+
'<span>I reviewed every thread and confirm this followup is still appropriate.</span></label>';
slot.className=''; slot.innerHTML='<div class="verdict '+boxClass+'">'+html+'</div>';
const sb=$('#sendBtn');
if(sb){
if(gated){ sb.disabled=true; sb.classList.add('btn-danger'); sb.classList.remove('btn-go');
const ov=$('#ovr'); if(ov) ov.onchange=()=>{ sb.disabled=!ov.checked; }; }
else { sb.disabled=false; sb.classList.add('btn-go'); sb.classList.remove('btn-danger'); }
}
}
async function sendPitch(d){
const subj=$('#pSubj').value.trim(), body=$('#pBody').value.trim();
if(!d.toEmail){ toast('No recipient on this draft',1); return; }
if(!body){ toast('Body is empty',1); return; }
const c = riskCache[d.toEmail];
let warn='Send this followup to '+d.toEmail+'?';
if(c && (c.risk==='high'||c.risk==='elevated')) warn='⚠ '+riskWord[c.risk]+' — '+c.signals.join(', ')+
' signals found. Send anyway to '+d.toEmail+'?';
if(!confirm(warn)) return;
const sb=$('#sendBtn'); sb.disabled=true; $('#sendState').innerHTML='<span class="spin"></span> sending…';
try{
const r=await fetch('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({to:d.toEmail,subject:subj,body:body.replace(/\n/g,'<br>'),threadId:d.threadId})});
const out=await r.json();
if(out.error) throw new Error(out.error);
$('#sendState').innerHTML='<span class="sent-stamp">✓ sent in-thread</span>';
toast('Followup sent to '+d.toEmail);
// Process finished successfully → auto-remove this draft (Steve 2026-06-30). The followup
// was sent, so the leftover Gmail draft is done: trash it (reversible → Gmail Trash, 30-day
// recovery) and drop it from the list immediately so it can't linger or be re-sent.
try{ const tr=await fetch('/api/draft/'+encodeURIComponent(d.id)+'/trash',{method:'POST'}); await tr.json(); }catch(_){}
DRAFTS = DRAFTS.filter(x=>x.id!==d.id);
if(CURRENT===d.id){
CURRENT=null;
const corr=$('#corr'); if(corr) corr.innerHTML='<div class="empty"><div class="big">✉</div>Pick a draft to see every thread<br>with that contact before you decide.</div>';
const pit=$('#pitch'); if(pit) pit.innerHTML='<div class="pitch-empty">No draft selected.<br>The followup waiting for this client will appear here.</div>';
}
renderDrafts();
}catch(e){ $('#sendState').textContent=''; sb.disabled=false; toast('Send failed: '+e.message,1); }
}
async function scanAll(){
const btn=$('#scanAll'); btn.disabled=true;
const targets=[...new Set(DRAFTS.filter(d=>d.kind!=='vendor'&&d.toEmail&&!verdictOf(d.toEmail)).map(d=>d.toEmail))];
if(!targets.length){ btn.disabled=false; toast('Every draft already scanned'); return; }
let i=0, done=0;
const tick=()=>{ btn.textContent='scanning '+done+'/'+targets.length; };
tick();
async function worker(){
while(i<targets.length){
const email=targets[i++];
try{ const r=await fetch('/api/risk?email='+encodeURIComponent(email)); const v=await r.json();
if(!v.error) liteCache[email]=v; }catch(e){}
done++; tick(); renderDrafts();
await new Promise(r=>setTimeout(r,250));
}
}
await Promise.all([worker(),worker()]);
btn.disabled=false; btn.textContent='Scan risk';
const high=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='high';}).length;
const elev=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='elevated';}).length;
toast('Scan done — '+high+' high-risk, '+elev+' elevated');
}
/* ════════ BOOT ════════ */
$('#scanAll').onclick=scanAll;
$('#resetLayout').onclick=resetLayout;
const sortSel=$('#sortSel');
if(sortSel){ sortSel.value=sortMode;
sortSel.onchange=()=>{ sortMode=sortSel.value; localStorage.setItem('pg.sort',sortMode); renderDrafts(); }; }
const soloDraft = applySoloMode();
if(!document.body.classList.contains('solo')){
applyLayout();
wireDragReorder();
}
wirePanelControls();
loadDrafts().then(()=>{ if(soloDraft) selectDraft(soloDraft); });
if(!document.body.classList.contains('solo')) autoLoadCandidates();
</script>
</body>
</html>