[object Object]

← back to Dw Pitch Followup

Email: thumbnail + product-link gallery for recent samples (dw_unified sku→handle/image, dashed-sku match, ACTIVE-only links); panel shows thumbnails too; separate sample-reminder heading from list

b2b886fbb8192c21eeae2f6df22db4b36c42fcbf · 2026-07-07 15:30:50 -0700 · Steve Abrams

Files touched

Diff

commit b2b886fbb8192c21eeae2f6df22db4b36c42fcbf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 15:30:50 2026 -0700

    Email: thumbnail + product-link gallery for recent samples (dw_unified sku→handle/image, dashed-sku match, ACTIVE-only links); panel shows thumbnails too; separate sample-reminder heading from list
---
 public/index.html      | 10 ++++++++--
 scripts/build-lists.js | 45 +++++++++++++++++++++++++++++++++++++++++++--
 server.js              | 45 ++++++++++++++++++++++++++++++++++++---------
 3 files changed, 87 insertions(+), 13 deletions(-)

diff --git a/public/index.html b/public/index.html
index 0f70958..18fe4e1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -248,7 +248,13 @@ function rerenderAll(){
 function samplesBlock(r){
   const sent=Array.isArray(r.samples_sent)?r.samples_sent:[], pend=Array.isArray(r.samples_pending)?r.samples_pending:[];
   if(!sent.length&&!pend.length)return'';
-  const li=(arr,cls,withDate)=>arr.slice(0,20).map(s=>{const nm=typeof s==='string'?s:(s.label||s.sku||'sample');const w=(withDate&&s&&s.date)?` · ${esc(fmtDay(s.date))}`:'';return `<li><span class="dot ${cls}"></span>${esc(nm)}${w}</li>`}).join('');
+  const li=(arr,cls,withDate)=>arr.slice(0,20).map(s=>{
+    const nm=typeof s==='string'?s:(s.label||s.sku||'sample');
+    const w=(withDate&&s&&s.date)?` · ${esc(fmtDay(s.date))}`:'';
+    const img=(s&&s.image)?`<img src="${esc(s.image)}" style="width:22px;height:22px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px;border:1px solid var(--line)">`:`<span class="dot ${cls}"></span>`;
+    const name=(s&&s.url)?`<a href="${esc(s.url)}" target="_blank">${esc(nm)}</a>`:esc(nm);
+    return `<li>${img}${name}${w}</li>`;
+  }).join('');
   let out='';
   if(sent.length)out+=`<div class="drow"><b>Samples sent (${sent.length})</b><ul class="slist">${li(sent,'ok',true)}</ul></div>`;
   if(pend.length)out+=`<div class="drow"><b>Still pending (${pend.length})</b><ul class="slist">${li(pend,'pend',false)}</ul></div>`;
@@ -342,7 +348,7 @@ async function sendActive(btn){
   if(!confirm(`Send this letter NOW?\n\nFROM:  info@designerwallcoverings.com\nTO:    ${to}\nSUBJ:  ${subj}\n\nThis emails the recipient immediately.`))return;
   const old=btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
   try{
-    const res=await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,subject:subj,body})});
+    const res=await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,subject:subj,body,samples:Array.isArray(r.samples_sent)?r.samples_sent:[]})});
     const j=await res.json(); if(j.error)throw new Error(j.blocked?`blocked by send-guard: ${j.error}`:j.error);
     btn.textContent='✓ Sent'; stat.style.color='var(--green)'; stat.textContent=`sent via info@${j.inThread?' · in-thread':''}`;
     setLS('sent:'+list+':'+k,{at:Date.now(),messageId:j.messageId||''});
diff --git a/scripts/build-lists.js b/scripts/build-lists.js
index 646a64b..ae8a923 100644
--- a/scripts/build-lists.js
+++ b/scripts/build-lists.js
@@ -9,6 +9,7 @@
 
 import fs from 'node:fs';
 import path from 'node:path';
+import { execFileSync } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
 import { findAll, findProjectsByAccounts, findRecords, fmDate, daysAgo } from '../lib/fm.js';
 
@@ -232,10 +233,11 @@ async function enrichSamples(accounts) {
   for (const rec of recs) {
     const fd = rec.fieldData;
     const acct = String(fd['account'] || '').trim(); if (!acct) continue;
-    const label = clean(fd['combo sku'] || '') || clean(fd['Mfr Pattern'] || '') || 'sample';
+    const sku = clean(fd['combo sku'] || '');
+    const label = sku || clean(fd['Mfr Pattern'] || '') || 'sample';
     const sent = fmToISO(fd['Date WP Sample Sent']);
     const g = map.get(acct) || { sent: [], pending: [] };
-    if (sent) g.sent.push({ label, date: sent }); else g.pending.push({ label });
+    if (sent) g.sent.push({ label, sku, date: sent }); else g.pending.push({ label, sku });
     map.set(acct, g);
   }
   for (const g of map.values()) {
@@ -294,6 +296,35 @@ async function enrichNames(accounts) {
   return map;
 }
 
+// FileMaker combo sku (undashed, e.g. DWC319153) → dw_unified dw_sku (dashed, DWC-319153).
+function dashSku(s) { return String(s || '').trim().replace(/^([A-Za-z]+)(\d.*)$/, '$1-$2'); }
+
+// Look up each sample's product page URL + thumbnail image from the local dw_unified catalog
+// (Postgres). Returns Map(dashedSku → { url, image }). URL only when the product is ACTIVE
+// (published) so client links never 404; image whenever present.
+function enrichSampleProducts(skus) {
+  const dashed = [...new Set(skus.map(dashSku).filter(Boolean))];
+  const map = new Map();
+  if (!dashed.length) return map;
+  const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
+  const CONN = process.env.DW_UNIFIED_URL || 'postgresql://localhost/dw_unified';
+  const arr = '{' + dashed.map((s) => '"' + s.replace(/[^A-Za-z0-9._-]/g, '') + '"').join(',') + '}';
+  const q = `SELECT dw_sku, handle, coalesce(image_url,''), status FROM shopify_products WHERE dw_sku = ANY('${arr}')`;
+  try {
+    const out = execFileSync(PSQL, [CONN, '-tAF', '\t', '-c', q], { maxBuffer: 128 * 1024 * 1024 }).toString();
+    for (const line of out.split('\n')) {
+      if (!line.trim()) continue;
+      const [dw_sku, handle, image, status] = line.split('\t');
+      const active = /^active$/i.test(status || '');
+      map.set(dw_sku, {
+        url: (handle && active) ? `https://designerwallcoverings.com/products/${handle}` : '',
+        image: image || '',
+      });
+    }
+  } catch (e) { console.error('[build-lists] product lookup failed:', e.message); }
+  return map;
+}
+
 // ====================================================================
 async function main() {
   const t0 = Date.now();
@@ -304,6 +335,16 @@ async function main() {
   console.log('[build-lists] enriching samples + merchandise invoices…');
   const accounts = [...new Set([...list1, ...list2].map((r) => String(r.account)))];
   const [samplesMap, merchMap, namesMap] = [await enrichSamples(accounts), await enrichMerch(accounts), await enrichNames(accounts)];
+  // Attach product page URL + thumbnail to each sample (from dw_unified).
+  const allSkus = [];
+  for (const g of samplesMap.values()) for (const it of [...g.sent, ...g.pending]) allSkus.push(it.sku || it.label);
+  const prodMap = enrichSampleProducts(allSkus);
+  let withImg = 0;
+  for (const g of samplesMap.values()) for (const it of [...g.sent, ...g.pending]) {
+    const p = prodMap.get(dashSku(it.sku || it.label));
+    if (p) { if (p.url) it.url = p.url; if (p.image) { it.image = p.image; withImg++; } }
+  }
+  console.log(`[build-lists] product-matched ${withImg} sample line(s) to images/links`);
   let withSamples = 0, withMerch = 0, withName = 0;
   for (const r of [...list1, ...list2]) {
     const fn = namesMap.get(String(r.account));
diff --git a/server.js b/server.js
index f3dfdf2..0ced87c 100644
--- a/server.js
+++ b/server.js
@@ -269,7 +269,7 @@ function composeDeterministic(list, row, ctx, seed, showMfr) {
     `Hi ${greetName},\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` : '') +
+    (sampleItems.length ? `Here’s a quick reminder of your recent samples…\n\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.`;
@@ -300,12 +300,39 @@ 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>`;
+const _esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+// A thumbnail + product-link gallery for the "recent samples" block (email-safe <table>).
+function sampleGallery(samples) {
+  const rows = samples.slice(0, 12).map((s) => {
+    const label = _esc(s.label || s.sku || 'sample');
+    const date = s.date ? `<span style="color:#999"> &nbsp;·&nbsp; sent ${_esc(new Date(s.date).toLocaleDateString())}</span>` : '';
+    const img = s.image
+      ? `<img src="${_esc(s.image)}" width="48" height="48" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid #e2ddd4;display:block">`
+      : `<div style="width:48px;height:48px;background:#efece6;border-radius:6px"></div>`;
+    const name = s.url ? `<a href="${_esc(s.url)}" style="color:#8a6d3b;text-decoration:none;font-weight:600">${label}</a>` : `<span style="font-weight:600">${label}</span>`;
+    return `<tr><td style="padding:5px 12px 5px 0;vertical-align:middle">${img}</td><td style="padding:5px 0;vertical-align:middle;font-size:14px;color:#2b2b2b">${name}${date}</td></tr>`;
+  }).join('');
+  return `<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:2px 0 14px">${rows}</table>`;
+}
+// Plain letter → styled HTML. If `samples` (with url/image) are supplied, the "• sample" block
+// is replaced by a thumbnail+link gallery; otherwise it renders as a plain bulleted list.
+function bodyToHtml(text, samples) {
+  const link = (s) => s
+    .replace(/\bdesignerwallcoverings\.com\b/gi, '<a href="https://designerwallcoverings.com" style="color:#8a6d3b;text-decoration:underline">designerwallcoverings.com</a>')
+    .replace(/1-888-373-4564/g, '<a href="tel:+18883734564" style="color:#8a6d3b;text-decoration:none">1-888-373-4564</a>');
+  const gallery = Array.isArray(samples) && samples.length ? samples : null;
+  const paras = String(text).split(/\n{2,}/).map((p) => {
+    const lines = p.split('\n');
+    if (lines.some((l) => /^\s*•/.test(l))) {
+      if (gallery) return sampleGallery(gallery);
+      const items = lines.filter((l) => l.trim()).map((l) => `<li style="margin:2px 0">${link(_esc(l.replace(/^\s*•\s*/, '')))}</li>`).join('');
+      return `<ul style="margin:2px 0 14px;padding-left:20px;color:#555">${items}</ul>`;
+    }
+    const isSig = /designerwallcoverings\.com/.test(p) || /^P\.S\./.test(p.trim());
+    const style = isSig ? 'margin:0 0 10px;color:#555;font-size:14px' : 'margin:0 0 13px';
+    return `<p style="${style}">${link(_esc(p)).replace(/\n/g, '<br>')}</p>`;
+  }).join('');
+  return `<div style="font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.6;color:#2b2b2b;max-width:600px">${paras}</div>`;
 }
 
 // GET /api/lastcorr?email= — newest email we exchanged with this address (George read-only).
@@ -328,7 +355,7 @@ app.get('/api/lastcorr', async (req, res) => {
 // we pass that back verbatim. Each send is a deliberate per-recipient click in the UI.
 app.post('/api/send', async (req, res) => {
   try {
-    const { to, subject, body } = req.body || {};
+    const { to, subject, body, samples } = req.body || {};
     if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject and body are required' });
     if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(to).trim())) return res.status(400).json({ error: `not a valid recipient email: ${to}` });
 
@@ -342,7 +369,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: bodyToHtml(body), source: 'dw-pitch-followup' };
+    const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body: bodyToHtml(body, samples), source: 'dw-pitch-followup' };
     if (replyToMessageId) payload.replyToMessageId = replyToMessageId;
 
     const headers = { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH };

← eb905c7 Letter greeting uses FileMaker contact first name (Find-a-cl  ·  back to Dw Pitch Followup  ·  Make panel 'Send now (info@)' prominent — filled gold, bold, a4a77d7 →