[object Object]

← back to Dw Pitch Followup

Rewrite client letter to spec: ~3 sentences, client-type aware (designer/architect trade line), final-purchase ask, witty stock/samples ask, project-details line, ends w/ question, rotating Steve/Samantha/Marcus sign-off, sample list w/ dates, url+phone (HTML href on send), PS; no new-year/no mfr#; fix name parsing (parentheticals/ALL-CAPS)

2246a8ec3668b509ccb4f9550ca7b3ad5e73d819 · 2026-07-07 15:09:22 -0700 · Steve Abrams

Files touched

Diff

commit 2246a8ec3668b509ccb4f9550ca7b3ad5e73d819
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 15:09:22 2026 -0700

    Rewrite client letter to spec: ~3 sentences, client-type aware (designer/architect trade line), final-purchase ask, witty stock/samples ask, project-details line, ends w/ question, rotating Steve/Samantha/Marcus sign-off, sample list w/ dates, url+phone (HTML href on send), PS; no new-year/no mfr#; fix name parsing (parentheticals/ALL-CAPS)
---
 scripts/build-lists.js |  2 ++
 server.js              | 90 ++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 79 insertions(+), 13 deletions(-)

diff --git a/scripts/build-lists.js b/scripts/build-lists.js
index f01c62f..1363a53 100644
--- a/scripts/build-lists.js
+++ b/scripts/build-lists.js
@@ -103,6 +103,7 @@ async function buildList1() {
       account: acct,
       company,
       email,
+      client_type: clean(pick(fd, [/^client type$/i])),
       sent, ordered, missing: nval(fd['Number of Samples Missing']),
       ratio: Math.round(ratio * 100),
       patterns: [...new Set(g.patterns)].slice(0, 8),
@@ -153,6 +154,7 @@ async function buildList2() {
       account: g.account,
       company: clean(g.company) || nameFromEmail(email) || `Account ${g.account}`,
       email,
+      client_type: p ? clean(pick(p.fieldData, [/^client type$/i])) : '',
       invoice_count: g.count,
       max_total: g.maxTotal,
       details: [...new Set(g.details)].slice(0, 6),
diff --git a/server.js b/server.js
index f6afda6..ae40beb 100644
--- a/server.js
+++ b/server.js
@@ -113,8 +113,13 @@ async function georgeContext(email) {
 
 // --- structured facts we hand to the writer (mfr-scrubbed for clients) ----
 function firstName(company) {
-  const t = String(company || '').trim().split(/[\s,]+/)[0] || 'there';
-  return /^account$/i.test(t) ? 'there' : t;
+  // Drop parenthetical prefixes like "(Fedex Please)" and shipping notes, then take the
+  // first real word; de-shout ALL-CAPS (THOM → Thom). Falls back to "there".
+  const s = String(company || '').replace(/\([^)]*\)/g, ' ').replace(/\b(fedex|ups|please|attn|c\/o)\b/gi, ' ').trim();
+  let t = (s.split(/[\s,]+/).find((w) => w && !/^\d+$/.test(w))) || '';
+  if (!t || /^account$/i.test(t)) return 'there';
+  if (t === t.toUpperCase() && t.length > 1) t = t[0] + t.slice(1).toLowerCase();
+  return t;
 }
 function buildFacts(list, row, ctx, showMfr) {
   const f = [];
@@ -214,14 +219,60 @@ function composeDeterministic(list, row, ctx, seed, showMfr) {
       engine: 'deterministic',
     };
   }
-  const pats = list === 'list1'
-    ? (row.patterns || []).map((p) => clientLabel(p, showMfr)).filter(Boolean).slice(0, 3).join(', ')
-    : (row.details || []).map((d) => clientLabel(d, showMfr)).filter(Boolean).slice(0, 2).join('; ');
-  return {
-    subject: list === 'list2' ? `Following up on your Designer Wallcoverings samples` : `Following up on your wallpaper samples`,
-    body: `Hi ${name},\n\n${op}${pats ? ` — ${pats}` : ''}. I hope they arrived safely and gave you a real feel for the material.${ref}\n\nHave you had a chance to live with them? If any feel right, I'd love to help with the next step — I can confirm current stock and pricing, or pull a few coordinating options to round out the look. And if none quite landed, tell me what's missing and I'll curate a fresh set.\n\nNo rush at all — just here whenever you're ready.\n\n${cl}\nDesigner Wallcoverings`,
-    engine: 'deterministic',
-  };
+  // ---- list1 / list2 client follow-up — Steve's spec (2026-07-07) ----
+  // Concise (~3-sentence core), witty, NEVER mentions a mfr#, never the new year.
+  // Identifies client type; rotates the 3-name sign-off; lists the actual samples w/ ship
+  // dates; asks for project details before a closing question; always ends the ask with a
+  // question; adds a PS. designerwallcoverings.com + phone are linkified when SENT (HTML).
+  const type = String(row.client_type || '').toLowerCase();
+  const isTrade = /interior designer|architect|designer/.test(type);
+
+  // The client's ACTUAL samples by their DW sku/name (combo sku is a DW sku, NOT a mfr#), + ship date.
+  const sentArr = Array.isArray(row.samples_sent) ? row.samples_sent : [];
+  let sampleItems = sentArr.map((s) => {
+    const nm = String(typeof s === 'string' ? s : (s.label || s.sku || '')).trim();
+    const d = (s && s.date) ? new Date(s.date) : null;
+    return nm ? `  • ${nm}${d && !isNaN(d) ? ` — sent ${d.toLocaleDateString()}` : ''}` : null;
+  }).filter(Boolean);
+  if (!sampleItems.length) {
+    const alt = (list === 'list1' ? row.patterns : row.details) || [];
+    sampleItems = alt.map((p) => clientLabel(p, showMfr)).filter(Boolean).slice(0, 8).map((n) => `  • ${n}`);
+  }
+  const proj = String(row.project || '').trim();
+  const projRef = proj ? ` for your ${proj} project` : '';
+
+  // seed-varied flavor so each Regenerate reads fresh
+  const greets = ['Hope your day is going well!', 'Hope all is well on your end!', 'Hope your week is off to a great start!', 'Hope you’re having a good one!'];
+  const wits = [
+    'If you’d like a few more colorways to play with — or a quick stock check before anything wanders off — just say the word.',
+    'Want more samples to compare, or should we confirm your favorites are still in stock before they sell through?',
+    'Happy to send more options or run a stock check on anything that caught your eye — no arm-twisting, promise.',
+    'Need another texture, a coordinating option, or a stock check? I’m one reply away.',
+  ];
+  const questions = ['What’s the next step you’d like us to take?', 'How can we help you get this across the finish line?', 'What would be most useful from us right now?', 'Where would you like to go from here?'];
+  const g = greets[(seed - 1) % greets.length];
+  const wit = wits[(seed - 1) % wits.length];
+  const q = questions[(seed - 1) % questions.length];
+
+  // sign-off rotates Steve / Samantha / Marcus every regenerate
+  const perms = [['Steve', 'Samantha', 'Marcus'], ['Samantha', 'Marcus', 'Steve'], ['Marcus', 'Steve', 'Samantha'], ['Steve', 'Marcus', 'Samantha'], ['Samantha', 'Steve', 'Marcus'], ['Marcus', 'Samantha', 'Steve']];
+  const nm = perms[(seed - 1) % perms.length];
+  const signNames = `${nm[0]}, ${nm[1]} and ${nm[2]}`;
+
+  const tradeLine = isTrade ? ' And since custom work and sourcing are our specialty, we can tailor anything here for your project or client.' : '';
+
+  // ~3 tight sentences: (1) follow-up + final-purchase ask, (2) witty samples/stock + we're-here-to-help
+  // [+trade], (3) project-details line then a closing question. Then the sample reminder + sign-off + PS.
+  const body =
+    `Hi ${name},\n\n` +
+    `${g} Just checking in on the samples we sent${projRef} — any favorites so far, and when are you thinking of making your final purchase? ${wit} We’re here to help however we can.${tradeLine}\n\n` +
+    `If a sample has been specified for a project, please let us know the Project Name and city/state as well as who will be purchasing the item. ${q}\n\n` +
+    (sampleItems.length ? `Here’s a quick reminder of your recent samples…\n${sampleItems.slice(0, 12).join('\n')}\n\n` : '') +
+    `${signNames} — Designer Wallcoverings\n` +
+    `designerwallcoverings.com  ·  1-888-373-4564\n\n` +
+    `P.S. If you’d like to hop on a quick call, just let us know a good time.`;
+
+  return { subject: 'Following up on your Designer Wallcoverings samples', body, engine: 'deterministic-v2' };
 }
 
 app.post('/api/letter', async (req, res) => {
@@ -233,8 +284,10 @@ app.post('/api/letter', async (req, res) => {
     if (list === 'list3') {
       result = composeDeterministic(list, row, ctx, seed, showMfr); // vendor note stays deterministic
     } else {
-      try { result = await composeLLM(list, row, ctx, seed, showMfr); }
-      catch (e) { result = { ...composeDeterministic(list, row, ctx, seed, showMfr), engineNote: 'LLM unavailable (' + e.message + ') — deterministic fallback' }; }
+      // Deterministic-primary: Steve's client-letter spec has too many hard rules
+      // (rotating sign-off, exact project line, PS, phone/URL, no new-year, no mfr#,
+      // sample list, end-with-question) to trust a small local model to hit every time.
+      result = composeDeterministic(list, row, ctx, seed, showMfr);
     }
     res.json({ subject: result.subject, body: result.body, context: ctx, variant: seed, engine: result.engine, engineNote: result.engineNote, cost: '$0 (local + George read)' });
   } catch (e) {
@@ -242,6 +295,17 @@ app.post('/api/letter', async (req, res) => {
   }
 });
 
+// Plain letter → simple HTML for sending (George sends text/html). Keeps the textarea
+// clean plain-text for editing, but the delivered email gets a real href on the domain +
+// a tel: link on the phone, and newlines become <br>. Everything is HTML-escaped first.
+function bodyToHtml(text) {
+  let h = String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+  h = h.replace(/\bdesignerwallcoverings\.com\b/gi, '<a href="https://designerwallcoverings.com">designerwallcoverings.com</a>');
+  h = h.replace(/1-888-373-4564/g, '<a href="tel:+18883734564">1-888-373-4564</a>');
+  h = h.replace(/\n/g, '<br>');
+  return `<div style="font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.55;color:#1a1a1a">${h}</div>`;
+}
+
 // GET /api/lastcorr?email= — newest email we exchanged with this address (George read-only).
 // Lazy per-card lookup (fired only when a card's details is expanded) so we never fan 600+
 // George searches at build time. Reuses georgeContext (info@ then steve-office).
@@ -276,7 +340,7 @@ app.post('/api/send', async (req, res) => {
       if (r.ok) { const j = await r.json(); const m = (j.messages || [])[0]; if (m && m.id) replyToMessageId = m.id; }
     } catch { /* thread best-effort only — fall through to a fresh send */ }
 
-    const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body, source: 'dw-pitch-followup' };
+    const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body: bodyToHtml(body), source: 'dw-pitch-followup' };
     if (replyToMessageId) payload.replyToMessageId = replyToMessageId;
 
     const headers = { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH };

← a350bea Right-panel detail view (context+last-corresponded on click+  ·  back to Dw Pitch Followup  ·  Letter greeting uses FileMaker contact first name (Find-a-cl eb905c7 →