[object Object]

← back to New Import Viewer

similar-pattern search: per-card '≈ Similar' button → /api/similar proxy to all.dw (creds server-side) → scored live substitutes in an in-card panel

3479a52e023c65c838d2df5e466d5c006cde2ff8 · 2026-07-13 12:11:47 -0700 · Steve Abrams

Files touched

Diff

commit 3479a52e023c65c838d2df5e466d5c006cde2ff8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 12:11:47 2026 -0700

    similar-pattern search: per-card '≈ Similar' button → /api/similar proxy to all.dw (creds server-side) → scored live substitutes in an in-card panel
---
 public/index.html | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 28 ++++++++++++++++++++++++++++
 2 files changed, 76 insertions(+)

diff --git a/public/index.html b/public/index.html
index 949130e..7722ce7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -125,6 +125,21 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .acts.haslive{border-left:2px solid var(--arch);padding-left:8px;margin-left:-2px}
 .act:disabled{opacity:.4;cursor:default}
 .actmsg{font-size:11px;color:var(--mut);min-height:14px}
+.act.simact.on{border-color:var(--acc);color:var(--acc)}
+.simpane:empty{display:none}
+.simpane{margin-top:6px}
+.simhead{font-size:11px;font-weight:700;color:var(--acc);text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;gap:8px;margin-bottom:7px}
+.simhead .simx{cursor:pointer;color:var(--mut);font-weight:400}
+.simhead .simload{color:var(--mut);font-weight:400;text-transform:none;letter-spacing:0}
+.simnone{font-size:11.5px;color:var(--mut)}
+.simgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
+.simcard{background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
+.simcard .simimg{width:100%;aspect-ratio:1/1;background:#1a1c24 center/cover no-repeat}
+.simcard .simb{padding:5px 6px 6px;display:flex;flex-direction:column;gap:1px}
+.simcard .simsku{font-size:10px;font-weight:700;color:var(--ink)}
+.simcard .simttl{font-size:10.5px;color:var(--ink);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.simcard .simvt{font-size:9.5px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.simcard .simprice{font-size:10.5px;font-weight:700;color:var(--acc);margin-top:1px}
 #more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
 #empty{padding:60px;text-align:center;color:var(--mut)}
 #selbar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:#11131bF2;backdrop-filter:blur(10px);
@@ -411,6 +426,7 @@ function card(p){
   const utils=`<div class="acts">
     ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
     ${p.dwSku?`<button class="act" data-copy="${esc(p.dwSku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
+    ${(p.dwSku||p.sku)?`<button class="act simact" onclick="loadSim(${p.id},'${esc(p.dwSku||p.sku)}',this)" title="Search the catalog for a similar pattern">≈ Similar</button>`:''}
     ${vendLink}${shopLink}${storeLink}
   </div>`;
   return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}" data-img="${p.image?1:0}">
@@ -445,6 +461,7 @@ function card(p){
       ${acts}
       ${utils}
       <div class="actmsg" id="msg-${p.id}"></div>
+      <div class="simpane" id="sim-${p.id}"></div>
     </div>
   </div></div>`;
 }
@@ -455,6 +472,37 @@ grid.addEventListener('click',e=>{
   const c=e.target.closest('.card');
   if(c)c.classList.toggle('info-open');
 });
+// "≈ Similar" → proxied all.dw /api/similar → attribute-scored LIVE substitutes rendered
+// in a panel inside this card's action drawer. Toggle: a second click closes it.
+async function loadSim(id,sku,btn){
+  const box=document.getElementById('sim-'+id); if(!box)return;
+  if(box.dataset.open==='1'){ box.dataset.open=''; box.innerHTML=''; if(btn)btn.classList.remove('on'); return; }
+  box.dataset.open='1'; if(btn)btn.classList.add('on');
+  box.innerHTML='<div class="simhead">Similar live patterns — '+esc(sku)+' <span class="simx" title="close">✕</span><span class="simload">searching…</span></div>';
+  box.querySelector('.simx').onclick=()=>{ box.dataset.open=''; box.innerHTML=''; if(btn)btn.classList.remove('on'); };
+  try{
+    const d=await (await fetch('/api/similar?sku='+encodeURIComponent(sku)+'&limit=12')).json();
+    const rows=(d&&d.rows)||[];
+    const ld=box.querySelector('.simload'); if(ld)ld.remove();
+    if(!rows.length){ box.insertAdjacentHTML('beforeend','<div class="simnone">No similar live patterns found.</div>'); return; }
+    const g=document.createElement('div'); g.className='simgrid'; box.appendChild(g);
+    rows.forEach(r=>{
+      const price=r.price?('$'+Number(r.price).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})):'';
+      const img=r.image||(r.images&&r.images[0])||'';
+      const c=document.createElement('div'); c.className='simcard';
+      c.innerHTML='<div class="simimg"></div><div class="simb"><div class="simsku">'+esc(r.sku||'—')+'</div>'
+        +'<div class="simttl" title="'+esc(r.title||'')+'">'+esc(r.title||'')+'</div>'
+        +'<div class="simvt">'+esc([r.vendor,r.type].filter(Boolean).join(' · '))+'</div>'
+        +(price?'<div class="simprice">'+price+'</div>':'')+'</div>';
+      if(img)c.querySelector('.simimg').style.backgroundImage='url("/img?u='+encodeURIComponent(img)+'")';
+      if(r.url){ c.style.cursor='pointer'; c.onclick=()=>window.open(r.url,'_blank','noopener,noreferrer'); }
+      g.appendChild(c);
+    });
+  }catch(e){
+    box.innerHTML='<div class="simnone">Similarity search unavailable — try again.</div>';
+    box.dataset.open=''; if(btn)btn.classList.remove('on');
+  }
+}
 function filsParams(p){
   for(const [k] of FIELDS)if(FIL[k])p.set(k,FIL[k]);
   const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
diff --git a/server.js b/server.js
index ced5574..b46000f 100644
--- a/server.js
+++ b/server.js
@@ -6,10 +6,29 @@
 // writes are GATED behind SHOPIFY_LIVE_WRITES=1 (default off → stage-only).
 // Zero npm deps — shells out to psql (same pattern as wallco-live-viewer).
 const http = require('http');
+const https = require('https');
 const fs = require('fs');
 const path = require('path');
 const { spawnSync, spawn } = require('child_process');
 
+// catalog similarity — proxy to all.designerwallcoverings.com /api/similar.
+// Credentials live server-side only (ALLDW_BASIC_AUTH env or default) — NEVER sent to the browser.
+const ALLDW_AUTH = 'Basic ' + Buffer.from(process.env.ALLDW_BASIC_AUTH || 'admin:DW2024!').toString('base64');
+function fetchSimilar(sku, limit) {
+  return new Promise((resolve) => {
+    const qs = new URLSearchParams({ sku });
+    if (limit) qs.set('limit', limit);
+    const r = https.request({
+      hostname: 'all.designerwallcoverings.com', path: '/api/similar?' + qs.toString(),
+      method: 'GET', timeout: 25000,
+      headers: { Authorization: ALLDW_AUTH, 'User-Agent': 'new-import-viewer' },
+    }, (resp) => { let body = ''; resp.on('data', d => body += d); resp.on('end', () => resolve({ status: resp.statusCode || 502, body })); });
+    r.on('timeout', () => r.destroy(new Error('timeout')));
+    r.on('error', () => resolve(null));
+    r.end();
+  });
+}
+
 const PORT = process.env.PORT || 9790;
 const PSQL = process.env.PSQL || '/opt/homebrew/opt/postgresql@14/bin/psql';
 const PGUSER = process.env.PGUSER || 'stevestudio2';
@@ -471,6 +490,15 @@ const server = http.createServer((req, res) => {
       return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
     }
     if (u.pathname === '/img') { serveImg(u.searchParams.get('u') || '', res); return; }
+    if (u.pathname === '/api/similar') {
+      const sku = u.searchParams.get('sku');
+      if (!sku) { res.writeHead(400, { 'content-type': 'application/json' }); return res.end('{"error":"no sku"}'); }
+      fetchSimilar(sku, u.searchParams.get('limit')).then((r) => {
+        if (!r) { res.writeHead(502, { 'content-type': 'application/json' }); return res.end('{"error":"catalog similarity unavailable"}'); }
+        res.writeHead(r.status, { 'content-type': 'application/json' }); res.end(r.body);
+      });
+      return;
+    }
     if (u.pathname === '/api/stats') {
       // Whole-catalog status breakdown for the header pills (vendor/term ignored here).
       // Served from STATS_CACHE so this endpoint NEVER blocks the single-threaded

← 0250f13 chore: lint (node --check ✓), v1.0.1 (session close)  ·  back to New Import Viewer  ·  auto-save: 2026-07-13T14:55:38 (1 files) — package.json 8f6d032 →