← back to Dw Collections Viewer
similar-pattern search: per-tile ≈ button → /api/similar proxy to all.dw (creds server-side) → scored live substitutes in a shared panel below the product slider
1d1ab8c2f68a8f02f42be44e14e4b967214a05fe · 2026-07-13 12:20:52 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit 1d1ab8c2f68a8f02f42be44e14e4b967214a05fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 13 12:20:52 2026 -0700
similar-pattern search: per-tile ≈ button → /api/similar proxy to all.dw (creds server-side) → scored live substitutes in a shared panel below the product slider
---
public/index.html | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 21 +++++++++++++++++++++
2 files changed, 74 insertions(+)
diff --git a/public/index.html b/public/index.html
index 83858e5..436567b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -147,6 +147,26 @@ aside { border-left: 1px solid var(--rule); background: var(--bg2);
background: var(--bg); border: 1px solid var(--rule); cursor: pointer;
display: flex; flex-direction: column; transition: border-color .15s, transform .1s; position: relative }
.products-slider .pcard:hover { border-color: var(--gold) }
+.products-slider .pcard { position: relative }
+.products-slider .pcard .simbtn { position:absolute; top:4px; right:4px; z-index:2; width:22px; height:22px; padding:0;
+ font-size:13px; line-height:20px; text-align:center; border:1px solid var(--rule); border-radius:6px;
+ background:rgba(0,0,0,.6); color:var(--gold); cursor:pointer; opacity:0; transition:opacity .12s }
+.products-slider .pcard:hover .simbtn { opacity:1 }
+.products-slider .pcard .simbtn:hover { border-color:var(--gold); background:#000 }
+#simPanel:empty { display:none }
+#simPanel { margin:0 0 24px; padding:16px 18px; border:1px solid var(--rule); border-radius:12px; background:rgba(0,0,0,.25) }
+#simPanel .simhead { font-size:12px; font-weight:700; color:var(--gold); text-transform:uppercase; letter-spacing:.08em;
+ display:flex; align-items:center; gap:10px; margin-bottom:12px }
+#simPanel .simx { cursor:pointer; color:var(--rule); font-weight:400; margin-left:auto }
+#simPanel .simload, #simPanel .simnone { color:#9a8f7a; font-weight:400; text-transform:none; letter-spacing:0; font-size:13px }
+#simPanel .simgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px }
+#simPanel .simcard { border:1px solid var(--rule); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; background:#000 }
+#simPanel .simcard .simimg { width:100%; aspect-ratio:1/1; background:#111 center/cover no-repeat }
+#simPanel .simcard .simb { padding:6px 8px 8px; display:flex; flex-direction:column; gap:2px }
+#simPanel .simcard .simsku { font-size:10.5px; font-weight:700; color:#e8e0d2 }
+#simPanel .simcard .simttl { font-size:11px; color:#cfc7b8; line-height:1.25; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
+#simPanel .simcard .simvt { font-size:9.5px; color:#9a8f7a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
+#simPanel .simcard .simprice { font-size:11px; font-weight:700; color:var(--gold); margin-top:1px }
.products-slider .pcard.selected { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) }
.products-slider .pcard .img { aspect-ratio: 1/1; background: #000; overflow: hidden }
.products-slider .pcard .img img { width: 100%; height: 100%; object-fit: cover; display: block }
@@ -646,6 +666,7 @@ function renderProductsSlider() {
return `<div class="pcard${sel}${lowResCls}" draggable="true" data-sku="${esc(sku)}" data-image="${esc(bigUrl || '')}" data-title="${esc(p.title || '')}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
<div class="check">${PRODUCT_SELECTED.has(sku) ? '✓' : ''}</div>
${badge}
+ <button class="simbtn" title="Search the catalog for a similar pattern" onclick="event.stopPropagation();loadSim('${esc(sku)}')">≈</button>
<div class="img">${img}</div>
<div class="label">${esc(p.title || sku)}</div>
</div>`;
@@ -705,6 +726,37 @@ function renderProductsSlider() {
}
}
+// "≈" on a product tile → proxied all.dw /api/similar → attribute-scored LIVE substitutes
+// rendered in a shared panel below the slider (tiles are too small for an inline drawer).
+async function loadSim(sku) {
+ const box = document.getElementById('simPanel');
+ if (!box) return;
+ box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ box.innerHTML = `<div class="simhead">Similar live patterns — ${esc(sku)} <span class="simload">searching…</span><span class="simx" title="close">✕</span></div>`;
+ box.querySelector('.simx').onclick = () => { box.innerHTML = ''; };
+ 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("' + largestShopifyImage(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>';
+ }
+}
+
function setSort(v) {
PRODUCTS_SORT = v;
localStorage.setItem('dwc_sort', v);
@@ -856,6 +908,7 @@ function renderDetail() {
<div class="section">
<h3>All products · newest first</h3>
<div class="products-slider" id="products-slider"></div>
+ <div id="simPanel"></div>
</div>
<div class="section">
diff --git a/server.js b/server.js
index 0f3fc43..113ee0c 100644
--- a/server.js
+++ b/server.js
@@ -351,6 +351,27 @@ app.use(express.json({ limit: '12mb' }));
app.get('/api/sites', (req, res) => res.json({ sites: listSites() }));
+// 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');
+app.get('/api/similar', (req, res) => {
+ const sku = String(req.query.sku || '');
+ if (!sku) return res.status(400).json({ error: 'no sku' });
+ const qs = new URLSearchParams({ sku });
+ if (req.query.limit) qs.set('limit', String(req.query.limit));
+ const preq = https.request({
+ hostname: 'all.designerwallcoverings.com', path: '/api/similar?' + qs.toString(),
+ method: 'GET', timeout: 25000,
+ headers: { Authorization: ALLDW_AUTH, 'User-Agent': 'dw-collections-viewer' },
+ }, (r) => {
+ let body = ''; r.on('data', d => body += d);
+ r.on('end', () => { res.status(r.statusCode || 502).type('application/json').send(body); });
+ });
+ preq.on('timeout', () => preq.destroy(new Error('timeout')));
+ preq.on('error', () => res.status(502).json({ error: 'catalog similarity unavailable' }));
+ preq.end();
+});
+
// Re-probe every site over HTTPS and cache the immediate redirect target.
// Surfaces parked / consolidated domains so the admin grid can flag them
// instead of pulling the redirect-target's hero and looking like a bug.
← aa7d928 snapshot before dep reinstall + pm2 restart
·
back to Dw Collections Viewer
·
chore: v0.2.0 — similar-pattern search feature (session clos 48ca48c →