← 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
A data/vendors.jsonM public/index.htmlM server.js
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 →