[object Object]

← back to All Designerwallcoverings

disco banner: 'search the catalog' is now a button — /api/similar scores live substitutes (material/style/color/type/vendor/title-token) and renders matched cards below the banner

80f0bae0336c21183a634c3bb048497beb8996ee · 2026-07-13 09:35:12 -0700 · Steve Abrams

Files touched

Diff

commit 80f0bae0336c21183a634c3bb048497beb8996ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 09:35:12 2026 -0700

    disco banner: 'search the catalog' is now a button — /api/similar scores live substitutes (material/style/color/type/vendor/title-token) and renders matched cards below the banner
---
 public/index.html | 41 ++++++++++++++++++++++++++++++++++++-
 server.js         | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 100 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index f404305..cb3750a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -123,6 +123,16 @@
   #discoBanner .dbsucc a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
   #discoBanner .dbsucc .arr { opacity:.7; margin:0 6px; }
   #discoBanner .dbnone { margin-top:6px; font-size:12.5px; color:#c99; }
+  /* "Search the catalog for a similar pattern" → button + inline results grid */
+  #discoBanner .dbfind { display:inline-block; margin-left:6px; padding:5px 14px; font:600 12.5px -apple-system,system-ui,sans-serif;
+    color:#ffb3a3; background:#3a1a1a; border:1px solid #c0392b; border-radius:6px; cursor:pointer; letter-spacing:.01em; }
+  #discoBanner .dbfind:hover { background:#4a2020; border-color:#e74c3c; color:#ffd0c5; }
+  #discoBanner .dbfind:disabled { opacity:.55; cursor:wait; }
+  #discoBanner .dbsimhead { margin-top:14px; font-size:12px; font-weight:700; color:#e8b3a8;
+    text-transform:uppercase; letter-spacing:.08em; }
+  #discoBanner .dbsimgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
+    gap:10px; margin-top:8px; }
+  #discoBanner .dbsimgrid .card { border-color:#4a2626; }
   .card.disco-red { outline:2px solid #c0392b; outline-offset:-2px; }
   .card.disco-red .disco-succ { display:block; margin-top:5px; font-size:11px; color:#ff9b8a; line-height:1.35; }
   .card.disco-red .disco-succ a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
@@ -478,7 +488,7 @@ function hideDiscoBanner() {
 }
 function succLineHTML(d) {
   const s = d.successor;
-  if (!s || !s.sku) return `<div class="dbnone">No live successor identified — search the catalog for a similar pattern.</div>`;
+  if (!s || !s.sku) return `<div class="dbnone">No live successor identified —<button type="button" class="dbfind" id="dbFindBtn">Search the catalog for a similar pattern</button></div><div id="discoSim"></div>`;
   const label = esc(s.sku + (s.title ? ' · ' + s.title : ''));
   return s.url
     ? `<div class="dbsucc">Now available as <span class="arr">→</span> <a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${label}</a></div>`
@@ -515,9 +525,38 @@ async function checkDiscontinued() {
     if (state.total === 0 || card) {
       discoBanner.innerHTML = `<div class="dbhead">⛔ Discontinued — ${esc(d.title || '')} (${esc(d.sku)})</div>${succLineHTML(d)}`;
       discoBanner.className = 'show';
+      const fb = discoBanner.querySelector('#dbFindBtn');
+      if (fb) fb.onclick = () => loadSimilar(d.sku);
     }
   } catch (e) { /* endpoint degrades silently — grid still works */ }
 }
+
+// "Search the catalog" button → /api/similar scores live products against the dead SKU's
+// attributes (material/style/color/type/vendor/title tokens) and the matches render below
+// the banner with the SAME card renderer as the main grid (hover-gallery, actions, links).
+async function loadSimilar(sku) {
+  const box = discoBanner.querySelector('#discoSim');
+  const btn = discoBanner.querySelector('#dbFindBtn');
+  if (!box) return;
+  if (btn) { btn.disabled = true; btn.textContent = 'searching…'; }
+  try {
+    const res = await fetch('/api/similar?sku=' + encodeURIComponent(sku) + '&limit=12');
+    const d = await res.json();
+    box.innerHTML = '';
+    if (!d.rows || !d.rows.length) {
+      box.innerHTML = `<div class="dbnone">No similar live patterns found.</div>`;
+    } else {
+      const h = document.createElement('div'); h.className = 'dbsimhead';
+      h.textContent = `Similar live patterns (${d.rows.length})`; box.appendChild(h);
+      const g = document.createElement('div'); g.className = 'dbsimgrid'; box.appendChild(g);
+      for (const r of d.rows) g.appendChild(card(r));
+    }
+    if (btn) btn.remove();   // one-shot — results replace the call-to-action
+  } catch (e) {
+    if (btn) { btn.disabled = false; btn.textContent = 'Search the catalog for a similar pattern'; }
+    box.innerHTML = `<div class="dbnone">Similarity search unavailable — try again.</div>`;
+  }
+}
 function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
 function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
 
diff --git a/server.js b/server.js
index 3d27543..e343a36 100644
--- a/server.js
+++ b/server.js
@@ -1090,6 +1090,65 @@ async function handleDiscontinued(req, res, u) {
   }
 }
 
+// ── /api/similar?sku=<dead sku>&limit=N — attribute-scored live substitutes ──────────
+// Powers the discontinued banner's "Search the catalog for a similar pattern" button.
+// Scores the in-RAM snapshot against the dead SKU's own row (Archived rows keep their
+// full color/style/material tags, so the reference attributes are already derived).
+// Falls back to a DB title lookup when the dead SKU predates the mirror snapshot.
+// Returns rows in the exact /api/products row shape so the client card renderer works
+// unchanged. Leak-safe: only ROWS (public-safe columns) ever leave the server.
+const SIM_STOP = new Set(['wallpaper', 'wallcovering', 'wallcoverings', 'fabric', 'sample',
+  'memo', 'the', 'and', 'with', 'collection', 'designer', 'wallcoverings|']);
+function simTokens(title) {
+  return [...new Set(String(title || '').replace(/\|.*$/, '').toLowerCase().split(/[^a-z0-9]+/)
+    .filter((t) => t.length >= 4 && !SIM_STOP.has(t)))];
+}
+function simKey(s) { return String(s || '').toLowerCase().replace(/-sample$/, '').replace(/[^a-z0-9]/g, ''); }
+async function handleSimilar(req, res, u) {
+  const raw = (u.searchParams.get('sku') || '').trim();
+  if (!raw) return sendJSON(res, 400, { error: 'sku required' });
+  const limit = Math.min(parseInt(u.searchParams.get('limit') || '12', 10) || 12, 50);
+  const key = simKey(raw);
+  // the reference (dead) row: prefer the snapshot row; else DB title fallback
+  let ref = ROWS.find((r) => simKey(r.sku) === key || (r.mfr_number && simKey(r.mfr_number) === key));
+  if (!ref && pool) {
+    try {
+      const { rows } = await pool.query(
+        `select coalesce(title,'') title, coalesce(vendor,'') vendor, coalesce(product_type,'') type
+         from shopify_products
+         where regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') = $1
+            or regexp_replace(lower(mfr_sku),'[^a-z0-9]','','g') = $1
+         limit 1`, [key]);
+      if (rows.length) {
+        ref = { sku: raw, title: rows[0].title, vendor: rows[0].vendor, type: rows[0].type,
+          pattern: null, colors: [], styles: [], materials: [] };
+      }
+    } catch { /* fall through to not-found */ }
+  }
+  if (!ref) return sendJSON(res, 200, { found: false, sku: raw, rows: [] });
+  const toks = simTokens(ref.title);
+  const colors = new Set(ref.colors || []), styles = new Set(ref.styles || []), materials = new Set(ref.materials || []);
+  const scored = [];
+  for (const r of ROWS) {
+    if (r.lifecycle !== 'Active on site' || !r.image) continue;   // substitutes must be live + shoppable
+    if (simKey(r.sku) === key) continue;
+    let s = 0;
+    for (const m of r.materials || []) if (materials.has(m)) s += 3;
+    for (const st of r.styles || []) if (styles.has(st)) s += 2;
+    for (const c of r.colors || []) if (colors.has(c)) s += 1.5;
+    if (ref.type && r.type === ref.type) s += 2;
+    if (ref.vendor && r.vendor === ref.vendor) s += 1;
+    if (ref.pattern && r.pattern && r.pattern.toLowerCase() === ref.pattern.toLowerCase()) s += 4;
+    if (toks.length) { const h = r.hay || String(r.title || '').toLowerCase(); for (const t of toks) if (h.includes(t)) s += 1; }
+    if (s > 0) scored.push([s, r]);
+  }
+  scored.sort((a, b) => b[0] - a[0] || String(a[1].sku).localeCompare(String(b[1].sku)));
+  return sendJSON(res, 200, {
+    found: true, sku: ref.sku, title: ref.title || '',
+    rows: scored.slice(0, limit).map(([score, { hay, ...r }]) => ({ ...r, similarity: score })),
+  });
+}
+
 // 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.
@@ -1178,6 +1237,7 @@ 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/shopify-push') { handleShopifyPush(req, res, u); return; }
   if (u.pathname === '/api/shopify-push-bulk') { handleShopifyPushBulk(req, res); return; }

← 301550f auto-save: 2026-07-09T22:42:31 (2 files) — data/vendors.json  ·  back to All Designerwallcoverings  ·  chore: v1.4.0 — similar-pattern search feature (session clos f5cefcb →