[object Object]

← back to Pitch Guard

Split drafts into Clients + collapsible Vendors section (vendor classifier from dw_unified)

e6f61dbb90c52cf86958ef129cc4933093499c1a · 2026-05-19 09:08:58 -0700 · Steve

Files touched

Diff

commit e6f61dbb90c52cf86958ef129cc4933093499c1a
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 09:08:58 2026 -0700

    Split drafts into Clients + collapsible Vendors section (vendor classifier from dw_unified)
---
 data/vendors.json | 301 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html |  57 ++++++++---
 server.js         |  33 +++++-
 3 files changed, 377 insertions(+), 14 deletions(-)

diff --git a/data/vendors.json b/data/vendors.json
new file mode 100644
index 0000000..6febab2
--- /dev/null
+++ b/data/vendors.json
@@ -0,0 +1,301 @@
+{
+"domains": [
+"1838wallcoverings.com",
+"aestheticswall.com",
+"amazon.com",
+"ananbo.com",
+"arc-com.com",
+"arte-international.com",
+"astek.com",
+"auxabris.com",
+"barbarossaleather.com",
+"belartestudio.com",
+"bradbury.com",
+"brewsterhome.com",
+"brunschwig.com",
+"business.amazon.com",
+"carnegiefabrics.com",
+"chivasso.jab.de",
+"cole-and-son.com",
+"concertex.com",
+"cowtan.com",
+"crezanadesign.com",
+"custhom.co.uk",
+"dedar.com",
+"degournay.com",
+"designersguild.com",
+"designs.cowtan.com",
+"designtex.com",
+"dl-couch.com",
+"dropitmodern.com",
+"elitis.fr",
+"elitis.us",
+"eykon.com",
+"fabricut.com",
+"farrow-ball.com",
+"fortuny.com",
+"fromental.com",
+"fschumacher.com",
+"fsco.com",
+"genon.com",
+"gpjbaker.com",
+"grahambrown.com",
+"groundworksdesign.com",
+"harlequin.design",
+"hollyhunt.com",
+"hyggeandwest.com",
+"innovationsusa.com",
+"innovationswallcovering.com",
+"jab.de",
+"janeshelton.com",
+"jffabrics.com",
+"jimthompsonfabrics.com",
+"kanchidesigns.com",
+"kirkbydesign.com",
+"koroseal.com",
+"kravet.com",
+"leejofa.com",
+"lenleather.com",
+"lincrusta.com",
+"maharam.com",
+"majilite.com",
+"maxwellfabrics.com",
+"mayaromanoff.com",
+"mdcwall.com",
+"miltonandking.com",
+"mindtheg.com",
+"missonihome.com",
+"momentumtextiles.com",
+"momentumtextilesandwalls.com",
+"mulberryhome.com",
+"nationalwallcovering.com",
+"nobilis.fr",
+"omexco.com",
+"osborneandlittle.com",
+"paretewalls.com",
+"perennialsfabrics.com",
+"philipjeffries.com",
+"phillipjeffries.com",
+"pierrefrey.com",
+"porterteleo.com",
+"prismaflex.com",
+"ralphlaurenhome.com",
+"rfrancis.com",
+"romo.com",
+"rwltd.com",
+"sanderson-uk.com",
+"sangetsu.com",
+"scalamandre.com",
+"schumacher.com",
+"scionliving.com",
+"spinneybeck.com",
+"spoonflower.com",
+"staples.com",
+"studioditte.nl",
+"thibaut.com",
+"thibautdesign.com",
+"threadsfabrics.com",
+"tri-kes.com",
+"trikes.com",
+"uline.com",
+"vahallan.com",
+"versacehome.com",
+"versadesignedsurfaces.com",
+"vescom.com",
+"wallcoveringsmart.com",
+"wallquest.com",
+"william-morris.co.uk",
+"winfieldthybony.com",
+"wmorrisandco.com",
+"wolf-gordon.com",
+"wolfgordon.com",
+"yorkwall.com",
+"yorkwallcoverings.com",
+"zeuxis-parrhasius.com",
+"zoffany.com",
+"zuber.fr"
+],
+"names": [
+"1838 Wallcoverings",
+"ARTE' FORMS",
+"Abel Macias",
+"Aesthetics Wall",
+"Amazon Business",
+"Ananbo",
+"Andrew Martin",
+"Anna French",
+"Apartment Wallpaper",
+"Arbor Wood Wallcoverings",
+"Arc-Com Digital Solutions",
+"Arc-Com Wall Protection",
+"Arc-Com Wall Surfaces",
+"Architectural Fabrics",
+"Architectural Wallcoverings",
+"Arte International",
+"Arte International Wallcoverings",
+"Arte Specialty",
+"Aux Abris",
+"Barbarossa Leather",
+"Belarte Studio",
+"Bradbury & Bradbury",
+"Brewster",
+"Brewster Home Fashions",
+"Brewster Wallcoverings",
+"British Walls",
+"Brunschwig & Fils",
+"Carnegie Fabrics",
+"Catchii Netherlands Europe",
+"Celerie Kemble",
+"China Seas Wallpaper",
+"Chivasso",
+"Christian Lacroix Europe",
+"Cole & Son",
+"Cole & Son Wallcoverings",
+"Colefax and Fowler",
+"Concertex",
+"Contrado",
+"Cowtan & Tout",
+"Cowtan & Tout Wallcoverings",
+"Crezana Design",
+"Custhom",
+"DW",
+"DW Bespoke Murals",
+"DW Bespoke Studio",
+"DW Bespoke Studios To Go",
+"Daisy Bennett",
+"Dedar",
+"Designer Wallcoverings",
+"Designers Guild",
+"Designtex",
+"Drop It Modern",
+"Elitis",
+"Elitis Panoramic",
+"Elitis Wallcovering",
+"Elitis Wallcoverings",
+"Elitis Wallpaper",
+"Fabricut/Stroheim",
+"Farrow & Ball",
+"Flex Wall Protection",
+"Fortuny",
+"Fromental",
+"GP & J Baker",
+"GP & J Baker Direct",
+"Gaston Y Daniela",
+"Glambeads by DW",
+"Glitter Walls",
+"Graduate Collection",
+"Graduate Collection UK",
+"Graham & Brown",
+"Groundworks",
+"Hand Crafted",
+"Harlequin",
+"Holly Hunt",
+"Hollywood Acoustical",
+"Hollywood Wallcoverings",
+"Hygge & West",
+"Innovations",
+"Innovations Wallcoverings",
+"JAB",
+"JF Fabrics",
+"Jeffrey Stevens",
+"Jim Thompson",
+"Kanchi Designs",
+"Kirkby Design",
+"Koroseal",
+"Koroseal Digital Designs",
+"Koroseal Specialty",
+"Koroseal Wallcoverings",
+"Koroseal | Arte Type 2 Vinyls",
+"Koroseal | Koroseal Type 2 Vinyl",
+"Koroseal | Republic of II by IV",
+"Kravet",
+"Kravet Couture",
+"Kravet Design",
+"Kravet Wallcoverings",
+"Laura Ashley Wallpaper",
+"Lee Jofa",
+"Lee Jofa Modern",
+"Limited Stock Flock",
+"Lincrusta",
+"Maharam",
+"Malibu Wallpaper",
+"Malibu Walls",
+"Marian McEvoy",
+"Marimekko Exclusive",
+"Mark Alexander",
+"Maxwell Fabrics",
+"Maya Romanoff",
+"Maya Romanoff Wallcoverings",
+"Milton & King",
+"Mind The Gap",
+"Missoni Home",
+"Momentum Textiles and Wallcovering",
+"Morris & Co",
+"Mulberry Home",
+"NLXL",
+"Nina Campbell",
+"Nina Campbell Fabrics",
+"Nobilis",
+"Omexco",
+"Osborne & Little",
+"PR Faux Leather",
+"Parete",
+"Paul Montgomery",
+"Paul Montgomery Original Art",
+"Paul Montgomery Studios",
+"Perennials",
+"Phillip Jeffries",
+"Phillip Jeffries Wallcoverings",
+"Phillipe Romano",
+"Pierre Deux",
+"Pierre Frey",
+"Porter Teleo",
+"Primo Leathers",
+"RW Guild",
+"Ralph Lauren",
+"Ralph Lauren Fabrics",
+"Ralph Lauren Home",
+"Ralph Lauren Wallcoverings",
+"Retro DW Bespoke Studios To Go",
+"Retro Walls",
+"Roberto Cavalli",
+"Romo",
+"Romo Wallcoverings",
+"Sanderson",
+"Scalamandre",
+"Schumacher",
+"Schumacher Fabrics",
+"Schumacher USA",
+"Schumacher Wallcoverings",
+"Scion Living",
+"Seacloth",
+"Showroom Line",
+"Spoonflower",
+"Staples",
+"Studio Ditte",
+"Thibaut",
+"Thibaut Wallcoverings",
+"Threads",
+"Traditional Whimsy",
+"Tres Tintas",
+"Uline",
+"Vahallan",
+"Versa",
+"Versace Home",
+"Vescom Specialty",
+"Wallpaper Palace Sweden",
+"Wallquest (Murals)",
+"Wallquest (String papers)",
+"Wallquest Commercial",
+"Wallquest Residential",
+"William Morris & Co",
+"Winfield Thybony",
+"Wolf-Gordon",
+"York Wallcoverings",
+"Zeuxis Parrhasius",
+"Zimmer Rohde",
+"Zoffany",
+"Zuber",
+"de Gournay"
+]
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 9dcbcde..fe31b47 100644
--- a/public/index.html
+++ b/public/index.html
@@ -71,6 +71,15 @@
   .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)}
   .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)}
@@ -206,6 +215,7 @@
 <script>
 const $ = s => document.querySelector(s);
 let DRAFTS = [], CURRENT = null, MORE = false;
+let vendorsOpen = localStorage.getItem('pg.vendorsOpen') === '1';
 const riskCache = {}; // email -> full /api/contact result
 const liteCache = {}; // email -> light /api/risk result
 const verdictOf = email => riskCache[email] || liteCache[email] || null;
@@ -372,23 +382,44 @@ async function loadDrafts(){
     renderDrafts();
   }catch(e){ const el = $('#draftList'); if(el) el.innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
 }
+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>'+
+  '</div>';
+}
 function renderDrafts(){
   const el = $('#draftList');
   if(!el) return; // drafts panel popped out / not in this window
   if(!DRAFTS.length){ el.innerHTML='<div class="empty">No drafts waiting.</div>'; return; }
-  el.innerHTML = (MORE?'<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>':'') +
-  DRAFTS.map(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>'+
-    '</div>';
-  }).join('');
+  const clients = DRAFTS.filter(d=>d.kind!=='vendor');
+  const vendors = DRAFTS.filter(d=>d.kind==='vendor');
+  let html = MORE?'<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));
+  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 selectDraft(id){
@@ -528,7 +559,7 @@ async function sendPitch(d){
 
 async function scanAll(){
   const btn=$('#scanAll'); btn.disabled=true;
-  const targets=[...new Set(DRAFTS.filter(d=>d.toEmail&&!verdictOf(d.toEmail)).map(d=>d.toEmail))];
+  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; };
diff --git a/server.js b/server.js
index ae891d1..011371b 100644
--- a/server.js
+++ b/server.js
@@ -15,6 +15,7 @@
  */
 const express = require('express');
 const path = require('path');
+const fs = require('fs');
 
 const app = express();
 app.use(express.json({ limit: '4mb' }));
@@ -57,6 +58,33 @@ const SIGNALS = [
   { key: 'invoiced', level: 2, label: 'INVOICED', re: /\b(invoice ?#?\d|invoice attached|here('| i)s your invoice|invoice for)\b/i },
 ];
 
+// ── vendor vs client classification ──────────────────────────────────────
+// The followup bot drafts "following on the quote" replies to vendor support
+// desks too. Those aren't clients to pitch — they get their own section.
+const SELF_DOMAINS = new Set(['designerwallcoverings.com']);
+const VENDOR_ROLE = /^(cs|customerservice|customerservices|customercare|custserv|customer\.service|trade)$/i;
+let VENDOR_DOMAINS = new Set(), VENDOR_SLUGS = new Set();
+try {
+  const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'vendors.json'), 'utf-8'));
+  VENDOR_DOMAINS = new Set((v.domains || []).map(d => String(d).toLowerCase()));
+  VENDOR_SLUGS = new Set((v.names || [])
+    .map(n => String(n).toLowerCase().replace(/[^a-z0-9]/g, ''))
+    .filter(s => s.length >= 4));
+  console.log(`vendor list: ${VENDOR_DOMAINS.size} domains, ${VENDOR_SLUGS.size} name-slugs`);
+} catch (e) { console.warn('vendors.json not loaded — every draft will count as a client'); }
+
+function classifyRecipient(email) {
+  email = String(email || '').toLowerCase();
+  if (!email.includes('@')) return 'client';
+  const [lp, dom] = email.split('@');
+  if (SELF_DOMAINS.has(dom)) return 'vendor';        // DW-internal, not a client
+  for (const d of VENDOR_DOMAINS) { if (dom === d || dom.endsWith('.' + d)) return 'vendor'; }
+  const namePart = dom.split('.').slice(0, -1).join('').replace(/[^a-z0-9]/g, '');
+  if (namePart.length >= 4 && VENDOR_SLUGS.has(namePart)) return 'vendor';
+  if (VENDOR_ROLE.test(lp)) return 'vendor';
+  return 'client';
+}
+
 // in-memory contact-scan cache (TTL 10 min)
 const cache = new Map();
 const TTL = 10 * 60 * 1000;
@@ -94,8 +122,11 @@ app.get('/api/drafts', async (req, res) => {
       toName: displayName(m.to) || extractEmail(m.to) || '(no recipient)',
       date: m.date || '',
       snippet: m.snippet || '',
+      kind: classifyRecipient(extractEmail(m.to)),
     }));
-    const payload = { account: `${ACCOUNT}@${OURS}`, count: drafts.length, more, drafts };
+    const vendorCount = drafts.filter(d => d.kind === 'vendor').length;
+    const payload = { account: `${ACCOUNT}@${OURS}`, count: drafts.length,
+      clientCount: drafts.length - vendorCount, vendorCount, more, drafts };
     draftsCache = { t: Date.now(), v: payload };
     res.json(payload);
   } catch (e) { res.status(e.status || 500).json({ error: e.message }); }

← 5c43a5e Pitch Guard: resizable / collapsible / reorderable / pop-out  ·  back to Pitch Guard  ·  Add recent-followup signal, draft delete (trash), and sort b fba50d0 →