← 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
M public/index.htmlM server.js
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 →