← back to All Designerwallcoverings
Email Vendor everywhere: stock & price inquiry compose on every product card + microsite card — /api/vendor-contact + /api/vendor-inquiry (vendor_contacts upsert + George Gmail DRAFT, never auto-send, mailto fallback)
3fa3f9f8d235cf2fdaf45cd494a2b28ae4bf41c1 · 2026-07-15 13:33:23 -0700 · Steve Abrams
Files touched
M public/index.htmlM public/microsites.htmlM server.js
Diff
commit 3fa3f9f8d235cf2fdaf45cd494a2b28ae4bf41c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 15 13:33:23 2026 -0700
Email Vendor everywhere: stock & price inquiry compose on every product card + microsite card — /api/vendor-contact + /api/vendor-inquiry (vendor_contacts upsert + George Gmail DRAFT, never auto-send, mailto fallback)
---
public/index.html | 122 ++++++++++++++++++++++++++++++++++++++++++++++++-
public/microsites.html | 95 ++++++++++++++++++++++++++++++++++++++
server.js | 115 ++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 331 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index 9eb4af7..0612f0f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -190,6 +190,25 @@
.livepop .lcost { color:var(--accent); }
.lspin { display:inline-block; animation:lspin 1s linear infinite; }
@keyframes lspin { to { transform:rotate(360deg); } }
+ /* Email Vendor — the stock & price inquiry EVERY vendor gets (blue = correspondence).
+ Composes a Gmail DRAFT via George; never auto-sends. */
+ .acts .im.mailbtn, td.acts-td .im.mailbtn { border-color:#1a2a3a; color:#8ab6e0; }
+ .acts .im.mailbtn:hover, td.acts-td .im.mailbtn:hover { border-color:#8ab6e0; }
+ .mailpop { margin-top:2px; padding:7px 8px; border-radius:5px; background:#0c1218; border:1px solid #1a2a3a;
+ font-size:calc(10.5px*var(--fs)); display:flex; flex-direction:column; gap:4px; }
+ .mailpop[hidden] { display:none; }
+ .mailpop label { color:var(--mut); font-size:calc(9.5px*var(--fs)); letter-spacing:.04em; }
+ .mailpop input, .mailpop textarea { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
+ border-radius:5px; padding:5px 7px; font:inherit; font-size:calc(10.5px*var(--fs)); }
+ .mailpop textarea { min-height:120px; resize:vertical; line-height:1.45; }
+ .mailpop .mrow { display:flex; gap:5px; margin-top:2px; }
+ .mailpop .mgo { flex:1; padding:5px; border-radius:5px; border:1px solid #1a2a3a; background:transparent; color:#8ab6e0;
+ font-family:inherit; font-weight:600; cursor:pointer; font-size:calc(10.5px*var(--fs)); }
+ .mailpop .mgo:hover { border-color:#8ab6e0; }
+ .mailpop .mgo:disabled { opacity:.5; cursor:wait; }
+ .mailpop a.mgo { text-align:center; text-decoration:none; }
+ .mailpop .mstat { font-size:calc(10px*var(--fs)); line-height:1.4; }
+ .mailpop .mstat.ok { color:#7fc98a; } .mailpop .mstat.err { color:#e08b8b; }
/* list-view compact link cluster */
td.acts-td { white-space:nowrap; }
td.acts-td a, td.acts-td .intbtn { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin:0 4px 3px 0; background:transparent; color:var(--accent); cursor:pointer; font-family:inherit; }
@@ -724,7 +743,11 @@ function actButtonsHTML(r) {
const push = r.fm_present
? `<button class="im pushbtn" type="button" data-sku="${esc(r.sku || '')}" title="Preview the FileMaker→Shopify update (name / mfr / width / specs) — dry-run">Update Shopify ⇪</button><div class="pushpop" hidden></div>`
: '';
- return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${push}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
+ // Email Vendor = the stock & price path EVERY vendor gets (Steve 2026-07-15: no dead-end
+ // buttons — live_capable vendors scrape, everyone can be emailed; also a confirmation path
+ // for the live ones). Opens a compose panel; "Create Gmail draft" NEVER auto-sends.
+ const mail = `<button class="im mailbtn" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button><div class="mailpop" hidden></div>`;
+ return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${mail}${push}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
}
// Delegated within an .acts container (which stops propagation so the card's own
@@ -736,6 +759,8 @@ function handleActsClick(e) {
if (sb) { const pop = sb.closest('.intpanel').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
const lb = e.target.closest('.livebtn');
if (lb) { fireLiveCheck(lb); return; }
+ const mb = e.target.closest('.mailbtn');
+ if (mb) { toggleMailPop(mb); return; }
const cb = e.target.closest('.commitbtn');
if (cb) { fireCommit(cb); return; }
const pb = e.target.closest('.pushbtn');
@@ -819,6 +844,101 @@ function fireLiveCheck(lb) {
.finally(() => { lb.dataset.busy = ''; lb.textContent = orig; });
}
+// ── Email Vendor — stock & price inquiry compose (EVERY vendor; Gmail DRAFT only) ──────
+// To: pre-fills from /api/vendor-contact (and is REMEMBERED on submit via the server upsert,
+// so contact coverage grows as it's used). Professional trade-tone body asks the VENDOR for
+// availability / lead time / THEIR current net-trade price — our cost data never appears.
+// House signature block = the one dw-pitch-followup ships (address + 1-888-373-4564).
+const INQ_SIGNATURE = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
+function inquiryDefaults(d) { // d = { vendor, sku, mfr, pat } (all strings, possibly empty)
+ const ref = [d.mfr || d.sku, d.pat].filter(Boolean).join(' · ') || d.vendor || '';
+ const subject = `Stock & price check — ${ref} (Designer Wallcoverings)`;
+ const lines = ['Hello,', '',
+ 'This is Designer Wallcoverings (trade account) — could you check current stock and pricing on the following item for us?', ''];
+ if (d.pat) lines.push(` Pattern / colorway: ${d.pat}`);
+ if (d.mfr) lines.push(` Mfr SKU: ${d.mfr}`);
+ if (d.sku && d.sku !== d.mfr) lines.push(` Our ref: ${d.sku}`);
+ if (d.vendor) lines.push(` Line: ${d.vendor}`);
+ lines.push('', 'Specifically:',
+ ' • Current stock / availability',
+ ' • Lead time if not in stock',
+ ' • Current net / trade price',
+ '', INQ_SIGNATURE);
+ return { subject, body: lines.join('\n') };
+}
+const CONTACT_CACHE = {}; // vendor → {email} (session cache so repeat opens don't refetch)
+function toggleMailPop(mb) {
+ const pop = mb.parentElement.querySelector('.mailpop');
+ if (!pop) return;
+ if (!pop.hidden) { pop.hidden = true; return; }
+ pop.hidden = false;
+ if (!pop.dataset.built) buildMailPop(pop, mb.dataset);
+}
+async function buildMailPop(pop, d) {
+ pop.dataset.built = '1';
+ const def = inquiryDefaults(d);
+ pop.innerHTML = `
+ <label>To${d.vendor ? ' — ' + esc(d.vendor) + ' rep' : ''}</label>
+ <input type="text" class="mto" placeholder="vendor rep email — will be remembered">
+ <label>Subject</label>
+ <input type="text" class="msub">
+ <label>Message</label>
+ <textarea class="mbody"></textarea>
+ <div class="mrow"><button type="button" class="mgo mdraft">Create Gmail draft</button><a class="mgo mmailto" target="_blank" rel="noopener noreferrer">Open in mail app</a></div>
+ <div class="mstat" hidden></div>`;
+ pop.querySelector('.msub').value = def.subject;
+ pop.querySelector('.mbody').value = def.body;
+ const syncMailto = () => {
+ pop.querySelector('.mmailto').href = 'mailto:' + encodeURIComponent(pop.querySelector('.mto').value.trim())
+ + '?subject=' + encodeURIComponent(pop.querySelector('.msub').value)
+ + '&body=' + encodeURIComponent(pop.querySelector('.mbody').value);
+ };
+ pop.querySelectorAll('input,textarea').forEach((el) => el.addEventListener('input', syncMailto));
+ syncMailto();
+ pop.querySelector('.mdraft').addEventListener('click', () => fireInquiry(pop, d));
+ // pre-fill the remembered rep email for this vendor (grows via the server upsert)
+ if (d.vendor) {
+ try {
+ const c = CONTACT_CACHE[d.vendor]
+ || (CONTACT_CACHE[d.vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(d.vendor))).json());
+ const to = pop.querySelector('.mto');
+ if (c && c.email && !to.value) { to.value = c.email; syncMailto(); }
+ } catch (e) { /* unknown contact — placeholder stands */ }
+ }
+}
+async function fireInquiry(pop, d) {
+ const btn = pop.querySelector('.mdraft'), stat = pop.querySelector('.mstat');
+ const to = pop.querySelector('.mto').value.trim();
+ const subject = pop.querySelector('.msub').value.trim();
+ const body = pop.querySelector('.mbody').value;
+ stat.hidden = false; stat.className = 'mstat';
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { stat.className = 'mstat err'; stat.textContent = 'Enter the vendor rep’s email address first.'; return; }
+ if (!subject.trim() || !body.trim()) { stat.className = 'mstat err'; stat.textContent = 'Subject and message are required.'; return; }
+ btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
+ stat.textContent = '◌ Saving contact + creating Gmail draft…';
+ try {
+ const r = await fetch('/api/vendor-inquiry', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendor: d.vendor || '', to, subject, body, sku: d.sku || '' }),
+ });
+ const j = await r.json();
+ if (j.drafted) {
+ stat.className = 'mstat ok';
+ stat.textContent = '✓ draft saved in Gmail (' + (j.account || 'steve-office') + ') — review + send from there. Contact remembered.';
+ if (d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
+ } else if (j.ok) {
+ stat.className = 'mstat err';
+ stat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: '
+ + (j.reason || 'unknown') + ' — use “Open in mail app” instead.';
+ if (j.contact_saved && d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
+ } else {
+ stat.className = 'mstat err'; stat.textContent = j.reason || 'Request failed — try again.';
+ }
+ } catch (e) {
+ stat.className = 'mstat err'; stat.textContent = 'Request failed — try again.';
+ } finally { btn.disabled = false; btn.textContent = orig; }
+}
+
// Mfr # chip — shows BOTH Shopify's stored number AND what FileMaker Pro shows, side by side,
// with a ⚠ + tinted row when they disagree (the mfr-number verification workflow). FM value in
// green when it agrees. When the SKU isn't in the WALLPAPER file, just the Shopify number shows.
diff --git a/public/microsites.html b/public/microsites.html
index 3a083e9..4172b7d 100644
--- a/public/microsites.html
+++ b/public/microsites.html
@@ -61,6 +61,29 @@
.card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
.card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
+ /* per-card vendor inquiry button + page-level compose modal (Gmail DRAFT only, never auto-sends) */
+ .card .mailv { margin-top:12px; align-self:flex-start; background:none; border:1px solid var(--line); border-radius:2px;
+ padding:7px 12px; font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:var(--gold); cursor:pointer; }
+ .card .mailv:hover { border-color:var(--gold); }
+ #mailModal { position:fixed; inset:0; background:rgba(22,20,18,.55); display:none; align-items:center; justify-content:center; z-index:60; }
+ #mailModal.open { display:flex; }
+ #mailModal .mbox { background:#fff; border:1px solid var(--line); width:min(560px,94vw); max-height:88vh; overflow:auto;
+ padding:26px 28px; display:flex; flex-direction:column; gap:8px; }
+ #mailModal h3 { font-family:Georgia,serif; font-weight:400; font-size:19px; letter-spacing:.03em; }
+ #mailModal label { font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; margin-top:6px; }
+ #mailModal input, #mailModal textarea { width:100%; border:1px solid var(--line); background:#fdfcfa; padding:9px 11px;
+ font:inherit; font-size:13px; border-radius:2px; }
+ #mailModal textarea { min-height:180px; resize:vertical; line-height:1.5; }
+ #mailModal .mrow { display:flex; gap:10px; margin-top:10px; }
+ #mailModal .mgo { flex:1; padding:10px; border:1px solid var(--ink); background:var(--ink); color:#f2eee6;
+ font-size:11px; letter-spacing:.18em; text-transform:uppercase; cursor:pointer; text-align:center; text-decoration:none; }
+ #mailModal a.mgo { background:none; color:var(--ink); }
+ #mailModal .mgo:hover { border-color:var(--gold); }
+ #mailModal .mgo:disabled { opacity:.5; cursor:wait; }
+ #mailModal .mstat { font-size:12px; margin-top:6px; }
+ #mailModal .mstat.ok { color:#3d7a4a; } #mailModal .mstat.err { color:#a03c3c; }
+ #mailModal .mclose { align-self:flex-end; background:none; border:none; font-size:22px; cursor:pointer; color:#9a927f; }
+
.sentinel { height: 10px; }
footer { padding: 44px 34px 60px; text-align:center; font-size:11px; letter-spacing:.2em; text-transform:uppercase; color:#9a927f; border-top:1px solid var(--line); }
@media (max-width: 900px){ .grid{ --cols: 2 !important; } .controls{ top:63px } }
@@ -117,6 +140,25 @@
<main class="grid" id="grid"></main>
<div class="sentinel" id="sentinel"></div>
+<!-- vendor-level stock & price inquiry — creates a Gmail DRAFT via George; never auto-sends -->
+<div id="mailModal">
+ <div class="mbox">
+ <button class="mclose" aria-label="Close" onclick="mailModal.classList.remove('open')">×</button>
+ <h3 id="mmTitle">Stock & Price Inquiry</h3>
+ <label>To — vendor rep email</label>
+ <input type="text" id="mmTo" placeholder="vendor rep email — will be remembered">
+ <label>Subject</label>
+ <input type="text" id="mmSub">
+ <label>Message</label>
+ <textarea id="mmBody"></textarea>
+ <div class="mrow">
+ <button type="button" class="mgo" id="mmDraft">Create Gmail Draft</button>
+ <a class="mgo" id="mmMailto" target="_blank" rel="noopener noreferrer">Open in Mail App</a>
+ </div>
+ <div class="mstat" id="mmStat" hidden></div>
+ </div>
+</div>
+
<footer>Designer Wallcoverings & Fabrics — To The Trade & Beyond</footer>
<script>
@@ -217,11 +259,64 @@ function card(s){
<h3></h3>
<div class="sub">${sub}</div>
<div class="cta">Visit Microsite →</div>
+ <button type="button" class="mailv" title="Compose a stock & price inquiry to this vendor — saves a Gmail DRAFT, never auto-sends">✉ Stock & Price</button>
</div>`;
el.querySelector('h3').textContent = name;
+ // The card itself is an <a> — the inquiry button must not navigate.
+ el.querySelector('.mailv').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openMail(name); });
return el;
}
+// ── vendor-level stock & price inquiry (every vendor card — Steve 2026-07-15 rule) ──
+// Pre-fills the remembered rep email from /api/vendor-contact; "Create Gmail Draft" posts
+// /api/vendor-inquiry (upserts the contact + drafts via George — NEVER an auto-send).
+const mailModal = document.getElementById('mailModal');
+const INQ_SIG = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
+const CONTACTS = {};
+let mmVendor = '';
+const mmTo = document.getElementById('mmTo'), mmSub = document.getElementById('mmSub'),
+ mmBody = document.getElementById('mmBody'), mmStat = document.getElementById('mmStat'),
+ mmDraft = document.getElementById('mmDraft'), mmMailto = document.getElementById('mmMailto');
+function mmSync(){ mmMailto.href = 'mailto:' + encodeURIComponent(mmTo.value.trim())
+ + '?subject=' + encodeURIComponent(mmSub.value) + '&body=' + encodeURIComponent(mmBody.value); }
+[mmTo, mmSub, mmBody].forEach(el => el.addEventListener('input', mmSync));
+async function openMail(vendor){
+ mmVendor = vendor;
+ document.getElementById('mmTitle').textContent = 'Stock & Price Inquiry — ' + vendor;
+ mmTo.value = ''; mmStat.hidden = true; mmStat.textContent = '';
+ mmSub.value = `Stock & price check — ${vendor} line (Designer Wallcoverings)`;
+ mmBody.value = ['Hello,', '',
+ `This is Designer Wallcoverings (trade account) — we'd like to confirm current stock and pricing on your ${vendor} line.`, '',
+ 'Specifically:',
+ ' • Current stock / availability',
+ ' • Lead time if not in stock',
+ ' • Current net / trade price',
+ '', INQ_SIG].join('\n');
+ mailModal.classList.add('open');
+ mmSync();
+ try {
+ const c = CONTACTS[vendor] || (CONTACTS[vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(vendor))).json());
+ if (c && c.email && mmVendor === vendor && !mmTo.value) { mmTo.value = c.email; mmSync(); }
+ } catch(e) { /* unknown contact — placeholder stands */ }
+}
+mailModal.addEventListener('click', (e) => { if (e.target === mailModal) mailModal.classList.remove('open'); });
+mmDraft.addEventListener('click', async () => {
+ const to = mmTo.value.trim();
+ mmStat.hidden = false; mmStat.className = 'mstat';
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { mmStat.className = 'mstat err'; mmStat.textContent = 'Enter the vendor rep’s email address first.'; return; }
+ mmDraft.disabled = true; const orig = mmDraft.textContent; mmDraft.textContent = 'Drafting…';
+ mmStat.textContent = '◌ Saving contact + creating Gmail draft…';
+ try {
+ const r = await fetch('/api/vendor-inquiry', { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ vendor: mmVendor, to, subject: mmSub.value.trim(), body: mmBody.value, sku: '' }) });
+ const j = await r.json();
+ if (j.drafted) { mmStat.className = 'mstat ok'; mmStat.textContent = '✓ draft saved in Gmail (' + (j.account||'steve-office') + ') — review + send from there. Contact remembered.'; CONTACTS[mmVendor] = { email: to }; }
+ else if (j.ok) { mmStat.className = 'mstat err'; mmStat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: ' + (j.reason||'unknown') + ' — use "Open in Mail App" instead.'; if (j.contact_saved) CONTACTS[mmVendor] = { email: to }; }
+ else { mmStat.className = 'mstat err'; mmStat.textContent = j.reason || 'Request failed — try again.'; }
+ } catch(e) { mmStat.className = 'mstat err'; mmStat.textContent = 'Request failed — try again.'; }
+ finally { mmDraft.disabled = false; mmDraft.textContent = orig; }
+});
+
function renderMore(){
const next = state.view.slice(state.rendered, state.rendered + state.BATCH);
next.forEach(s=>grid.appendChild(card(s)));
diff --git a/server.js b/server.js
index e343a36..6b55595 100644
--- a/server.js
+++ b/server.js
@@ -1149,6 +1149,119 @@ async function handleSimilar(req, res, u) {
});
}
+// ── Vendor stock & price INQUIRY (the email path) ────────────────────────────────
+// Steve's standing rule (2026-07-15): EVERY display vendor gets a working "check stock
+// and price" action. live_capable vendors scrape their portal; everyone else (and live
+// ones too, as a confirmation path) gets an EMAIL to the vendor rep — composed here,
+// saved as a Gmail DRAFT via George. DRAFT ONLY, never an auto-send ($0, reversible).
+// GET /api/vendor-contact?vendor=<name> → stored rep email + last draft (case-insens.)
+// POST /api/vendor-inquiry {vendor,to,cc?,subject,body,sku}
+// → UPSERTs vendor_contacts (coverage grows organically as Steve uses it), then
+// creates the Gmail draft. George unreachable/unconfigured → the contact still
+// saves and {drafted:false, reason, mailto} lets the UI fall back to mailto:.
+// LEAK-SAFE: the payload carries only what the caller typed — no cost/net/wholesale ever
+// enters the compose (the whole point is asking the VENDOR for THEIR current price).
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_ENV = process.env.GEORGE_ENV_PATH || path.join(os.homedir(), 'Projects', 'george-gmail', '.env');
+const SECRETS_ENV = process.env.SECRETS_ENV_PATH || path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+// Drafts land in the steve-office (steve@designerwallcoverings.com) Gmail by default;
+// override with GEORGE_DRAFT_ACCOUNT=info to draft from info@ instead.
+const GEORGE_DRAFT_ACCOUNT = process.env.GEORGE_DRAFT_ACCOUNT || 'steve-office';
+function envFrom(file, key) {
+ try { const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; } catch { return ''; }
+}
+// Same resolution order dw-pitch-followup uses: canonical GEORGE_AUTH ("user:pass") from
+// process.env → secrets-manager master → george-gmail/.env → legacy PASS-only key.
+// Null (no cred found) is fine — the inquiry degrades to drafted:false + mailto.
+function resolveGeorgeAuth() {
+ const direct = process.env.GEORGE_AUTH || envFrom(SECRETS_ENV, 'GEORGE_AUTH') || envFrom(GEORGE_ENV, 'GEORGE_AUTH');
+ if (direct) return direct.startsWith('Basic ') ? direct : ('Basic ' + Buffer.from(direct.includes(':') ? direct : ('admin:' + direct)).toString('base64'));
+ const pass = process.env.GEORGE_BASIC_AUTH_PASS || envFrom(GEORGE_ENV, 'GEORGE_BASIC_AUTH_PASS');
+ return pass ? 'Basic ' + Buffer.from('admin:' + pass).toString('base64') : null;
+}
+const GEORGE_AUTH = resolveGeorgeAuth();
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const mailtoUrl = (to, subject, body) =>
+ `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+
+async function handleVendorContact(req, res, u) {
+ const vendor = (u.searchParams.get('vendor') || '').trim();
+ if (!vendor) return sendJSON(res, 400, { ok: false, reason: 'vendor required' });
+ if (!pool) return sendJSON(res, 503, { ok: false, reason: 'db not ready' });
+ try {
+ const { rows } = await pool.query(
+ `SELECT vendor, email, draft_subject, draft_body FROM vendor_contacts
+ WHERE lower(vendor) = lower($1) LIMIT 1`, [vendor]);
+ const r = rows[0] || null;
+ return sendJSON(res, 200, {
+ ok: true, vendor, known: !!r,
+ email: (r && r.email) ? r.email : null,
+ draft_subject: r ? (r.draft_subject || null) : null,
+ draft_body: r ? (r.draft_body || null) : null,
+ });
+ } catch (e) { return sendJSON(res, 500, { ok: false, reason: e.message }); }
+}
+
+async function handleVendorInquiry(req, res) {
+ let b = {};
+ try { b = JSON.parse(await readBody(req) || '{}'); } catch { /* bad body → field checks reject below */ }
+ const vendor = String(b.vendor || '').trim();
+ const to = String(b.to || '').trim();
+ const cc = String(b.cc || '').trim();
+ const subject = String(b.subject || '').trim();
+ const body = String(b.body || '').trim();
+ const sku = String(b.sku || '').trim();
+ if (!vendor) return sendJSON(res, 400, { ok: false, reason: 'vendor required' });
+ if (!EMAIL_RE.test(to)) return sendJSON(res, 400, { ok: false, reason: 'to= must be a valid email address' });
+ if (cc && !EMAIL_RE.test(cc)) return sendJSON(res, 400, { ok: false, reason: 'cc= must be a valid email address' });
+ if (!subject || !body) return sendJSON(res, 400, { ok: false, reason: 'subject and body required' });
+ const mailto = mailtoUrl(to, subject, body);
+
+ // 1. UPSERT the contact — coverage grows organically as Steve uses the compose. Update is
+ // case-insensitive against the existing vendor key so casing variants never dupe rows.
+ let saved = false;
+ if (pool) {
+ try {
+ const upd = await pool.query(
+ `UPDATE vendor_contacts SET email=$2, draft_subject=$3, draft_body=$4,
+ draft_saved_at=now(), updated_at=now()
+ WHERE lower(vendor) = lower($1)`, [vendor, to, subject, body]);
+ if (upd.rowCount === 0) {
+ await pool.query(
+ `INSERT INTO vendor_contacts (vendor, email, draft_subject, draft_body, draft_saved_at, updated_at)
+ VALUES ($1, $2, $3, $4, now(), now())`, [vendor, to, subject, body]);
+ }
+ saved = true;
+ } catch (e) { console.error('vendor_contacts upsert failed (non-fatal):', e.message); }
+ }
+
+ // 2. Gmail DRAFT via George — POST /api/drafts (NEVER /api/send). Any failure degrades
+ // gracefully: the contact is already saved and the UI gets the mailto: fallback.
+ if (!GEORGE_AUTH) {
+ return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+ reason: 'George credentials not configured on this host', mailto });
+ }
+ try {
+ const r = await fetch(`${GEORGE}/api/drafts`, {
+ method: 'POST', signal: AbortSignal.timeout(12000),
+ headers: { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH },
+ body: JSON.stringify({ to, ...(cc ? { cc } : {}), subject, body,
+ account: GEORGE_DRAFT_ACCOUNT, source: 'all-dw vendor inquiry' + (sku ? ' · ' + sku : '') }),
+ });
+ const d = await r.json().catch(() => ({}));
+ if (r.status === 200 && d.success) {
+ console.log(`vendor-inquiry: Gmail draft ${d.draftId} → ${to} (${vendor}${sku ? ' · ' + sku : ''}) [$0 — draft only, never sent]`);
+ return sendJSON(res, 200, { ok: true, drafted: true, gmail_draft_id: d.draftId,
+ account: d.account || GEORGE_DRAFT_ACCOUNT, contact_saved: saved, mailto });
+ }
+ return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+ reason: (d && d.error) || `George HTTP ${r.status}`, mailto });
+ } catch (e) {
+ return sendJSON(res, 200, { ok: true, drafted: false, contact_saved: saved,
+ reason: 'George unreachable: ' + e.message, mailto });
+ }
+}
+
// Basic Auth gate (Steve 2026-07-02: all. is published but un/pw-gated).
// Override with BASIC_AUTH=user:pass (or BASIC_AUTH_USER / BASIC_AUTH_PASS) in .env;
// default stays admin:DW2024! so behavior is byte-identical when unset. /healthz open.
@@ -1239,6 +1352,8 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/discontinued') { handleDiscontinued(req, res, u); return; }
if (u.pathname === '/api/similar') { handleSimilar(req, res, u); return; }
if (u.pathname === '/api/livestock') { handleLiveStock(req, res, u); return; }
+ if (u.pathname === '/api/vendor-contact') { handleVendorContact(req, res, u); return; }
+ if (u.pathname === '/api/vendor-inquiry' && req.method === 'POST') { handleVendorInquiry(req, res); return; }
if (u.pathname === '/api/shopify-push') { handleShopifyPush(req, res, u); return; }
if (u.pathname === '/api/shopify-push-bulk') { handleShopifyPushBulk(req, res); return; }
← 9317a40 chore: v1.5.0 (session close — pm2 viewer auto-discovery + /
·
back to All Designerwallcoverings
·
chore: vendor_contacts self-heal DDL, microsites compose val 016fb39 →