← 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
M public/index.htmlM scripts/build-lists.jsM server.js
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, '&').replace(/</g, '<').replace(/>/g, '>');
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
+// 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"> · 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 →