← back to All Designerwallcoverings
build full internal catalog for every unified vendor
c1420120edf33624a2ae64f8085f1149956eb43d · 2026-08-28 10:31:45 -0700 · Steve Abrams
Files touched
M .gitignoreA cta/CTA-REPORT.mdA cta/rec/chrome.mp4A cta/rec/safari.mp4A cta/results.jsonA cta/shots/chrome/catalog-after.pngA cta/shots/chrome/catalog-before.pngA cta/shots/safari/catalog-after.pngA cta/shots/safari/catalog-before.pngM lib/vendor-host.jsA public/vendor-internal.htmlM server.jsM test/vendor-host.test.jsM verification/e2e-proof.json
Diff
commit c1420120edf33624a2ae64f8085f1149956eb43d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 28 10:31:45 2026 -0700
build full internal catalog for every unified vendor
---
.gitignore | 1 +
cta/CTA-REPORT.md | 20 +++++++++++++++
cta/rec/chrome.mp4 | Bin 0 -> 73200 bytes
cta/rec/safari.mp4 | Bin 0 -> 52223 bytes
cta/results.json | 29 ++++++++++++++++++++++
cta/shots/chrome/catalog-after.png | Bin 0 -> 158425 bytes
cta/shots/chrome/catalog-before.png | Bin 0 -> 111294 bytes
cta/shots/safari/catalog-after.png | Bin 0 -> 56754 bytes
cta/shots/safari/catalog-before.png | Bin 0 -> 151361 bytes
lib/vendor-host.js | 7 +++++-
public/vendor-internal.html | 47 ++++++++++++++++++++++++++++++++++++
server.js | 13 ++++++++--
test/vendor-host.test.js | 11 ++++++++-
verification/e2e-proof.json | 30 +++++++++++++++++++++++
14 files changed, 154 insertions(+), 4 deletions(-)
diff --git a/.gitignore b/.gitignore
index 3abe6d6..749c327 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ data/microsites.json
# daily live-scrape spend ledger (runtime artifact for the $ kill-switch)
data/live-spend.json
logs/
+cta/rec/*/
# One-off backfill/rollback artifacts — ops data, keep out of the repo (recon-artifact hygiene)
data/backfills/
diff --git a/cta/CTA-REPORT.md b/cta/CTA-REPORT.md
new file mode 100644
index 0000000..c86bda9
--- /dev/null
+++ b/cta/CTA-REPORT.md
@@ -0,0 +1,20 @@
+# Internal vendor catalog click-through
+
+Verdict: **PASS** in Chromium and WebKit.
+
+Live target: `https://charles-burger-internal.designerwallcoverings.com/`
+Tested: 2026-08-28
+Authentication: HTTP Basic Auth
+
+| Component | Chromium | WebKit |
+|---|---|---|
+| Vendor-branded title | PASS | PASS |
+| Initial 83-card catalog | PASS | PASS |
+| Title sort | PASS | PASS |
+| Density slider → 4 columns | PASS | PASS |
+| Grid/list toggle | PASS | PASS |
+| Search “Diva” → 6 rows | PASS | PASS |
+| Created date and time visible | PASS | PASS |
+| Console errors | 0 | 0 |
+
+No Chrome/WebKit rendering mismatch was observed. Browserbase was not used; the tested URL was the production edge host itself.
diff --git a/cta/rec/chrome.mp4 b/cta/rec/chrome.mp4
new file mode 100644
index 0000000..01a89b5
Binary files /dev/null and b/cta/rec/chrome.mp4 differ
diff --git a/cta/rec/safari.mp4 b/cta/rec/safari.mp4
new file mode 100644
index 0000000..4ad7dd7
Binary files /dev/null and b/cta/rec/safari.mp4 differ
diff --git a/cta/results.json b/cta/results.json
new file mode 100644
index 0000000..904e224
--- /dev/null
+++ b/cta/results.json
@@ -0,0 +1,29 @@
+{
+ "tested_at": "2026-08-28T17:31:00.317Z",
+ "url": "https://charles-burger-internal.designerwallcoverings.com/",
+ "browserbase": "SKIP: edge host itself tested remotely; no clean-room connector used",
+ "report": [
+ {
+ "engine": "chrome",
+ "title": "Charles Burger · Internal Catalog · dw_unified",
+ "beforeCards": 83,
+ "searched": 6,
+ "cols": "4",
+ "list": true,
+ "created": "🕓 Jun 26, 2026, 12:40 AM",
+ "errors": [],
+ "pass": true
+ },
+ {
+ "engine": "safari",
+ "title": "Charles Burger · Internal Catalog · dw_unified",
+ "beforeCards": 83,
+ "searched": 6,
+ "cols": "4",
+ "list": true,
+ "created": "🕓 Jun 26, 2026 at 12:40 AM",
+ "errors": [],
+ "pass": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/cta/shots/chrome/catalog-after.png b/cta/shots/chrome/catalog-after.png
new file mode 100644
index 0000000..bfe798c
Binary files /dev/null and b/cta/shots/chrome/catalog-after.png differ
diff --git a/cta/shots/chrome/catalog-before.png b/cta/shots/chrome/catalog-before.png
new file mode 100644
index 0000000..ed1603e
Binary files /dev/null and b/cta/shots/chrome/catalog-before.png differ
diff --git a/cta/shots/safari/catalog-after.png b/cta/shots/safari/catalog-after.png
new file mode 100644
index 0000000..b3c11a3
Binary files /dev/null and b/cta/shots/safari/catalog-after.png differ
diff --git a/cta/shots/safari/catalog-before.png b/cta/shots/safari/catalog-before.png
new file mode 100644
index 0000000..a961a5a
Binary files /dev/null and b/cta/shots/safari/catalog-before.png differ
diff --git a/lib/vendor-host.js b/lib/vendor-host.js
index acef83e..652aca2 100644
--- a/lib/vendor-host.js
+++ b/lib/vendor-host.js
@@ -26,4 +26,9 @@ function vendorHostContext(hostHeader, vendorMap) {
return vendor ? { vendor, slug, internal, host } : { vendor: null, slug, internal, host };
}
-module.exports = { buildVendorHostMap, vendorHostContext };
+function vendorRootFile(context, pathname) {
+ if (!context || !context.vendor || !context.internal) return null;
+ return pathname === '/' || pathname === '/index.html' ? '/vendor-internal.html' : null;
+}
+
+module.exports = { buildVendorHostMap, vendorHostContext, vendorRootFile };
diff --git a/public/vendor-internal.html b/public/vendor-internal.html
new file mode 100644
index 0000000..87d37b8
--- /dev/null
+++ b/public/vendor-internal.html
@@ -0,0 +1,47 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <meta name="robots" content="noindex,nofollow">
+ <title>Vendor Catalog · dw_unified</title>
+ <style>
+ :root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#11131a;--ink:#eef0f5;--mut:#9aa0b0;--line:#292d38;--acc:#5eead4;--warn:#f59e0b;--good:#34d399;--bad:#f87171}
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+ header{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:10px 16px;background:#0c0d12ed;border-bottom:1px solid var(--line);backdrop-filter:blur(10px)}
+ h1{font-size:15px;margin:0}.pill,.chip{border-radius:999px;padding:2px 9px;font-size:11px;font-weight:700}.pill{background:var(--warn);color:#1a1205}.muted{color:var(--mut)}
+ button,select,input{font:inherit}button,select,input[type=search]{border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);padding:6px 9px}button{cursor:pointer}.control{display:flex;align-items:center;gap:6px}input[type=range]{accent-color:var(--acc)}
+ #layout{display:flex;align-items:flex-start}aside{position:sticky;top:53px;width:238px;min-width:238px;height:calc(100vh - 53px);overflow:auto;padding:10px;border-right:1px solid var(--line);background:#0e0f15}aside input{width:100%;margin-bottom:8px}
+ details{border-bottom:1px solid var(--line)}summary{cursor:pointer;padding:8px 3px;text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:700;color:var(--mut)}.facet{padding:0 0 8px}.frow{display:flex;justify-content:space-between;gap:8px;width:100%;border:0;background:transparent;padding:4px 7px;color:var(--mut);text-align:left}.frow:hover,.frow.on{background:var(--panel);color:var(--ink)}
+ main{min-width:0;flex:1}.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:8px;padding:10px 12px 70px}.card{min-width:0;background:var(--card);border:1px solid var(--line);border-radius:3px;overflow:hidden}.card:hover{border-color:#566174}.pic{aspect-ratio:1;background:#090a0e;position:relative}.pic img{width:100%;height:100%;object-fit:cover}.pic .alt{position:absolute;inset:0;opacity:0;transition:.18s}.pic:hover .alt{opacity:1}.body{padding:7px 8px;display:grid;gap:4px}.title{font-weight:650;line-height:1.25}.sku{font:11px ui-monospace,Menlo,monospace;color:var(--mut);overflow:hidden;text-overflow:ellipsis}.chips{display:flex;gap:4px;flex-wrap:wrap}.chip{border:1px solid var(--line);color:var(--mut);font-size:9px;padding:1px 6px}.chip.active{color:#08251b;background:var(--good);border-color:var(--good)}.chip.staged{color:#211604;background:var(--warn);border-color:var(--warn)}.when{font-size:10px;color:var(--mut)}.actions{display:flex;gap:5px;flex-wrap:wrap}.actions a{font-size:10px;color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:2px 6px}
+ #more,#empty{display:block;margin:10px auto 60px}#empty{text-align:center;color:var(--mut);padding:50px}.list{grid-template-columns:1fr}.list .card{display:grid;grid-template-columns:62px minmax(170px,2fr) minmax(110px,1fr) minmax(150px,1fr) 180px;align-items:center;padding-right:10px}.list .pic{width:52px;height:52px;margin:5px}.list .body{display:contents}.list .chips,.list .actions{white-space:nowrap;overflow:hidden}.list .when{padding-left:8px}
+ #filtersBtn,#closeFilters{display:none}@media(max-width:880px){#filtersBtn,#closeFilters{display:block}aside{display:none;position:fixed;inset:0;z-index:50;width:auto;height:auto;top:0}aside.open{display:block}.grid{grid-template-columns:repeat(2,minmax(0,1fr));padding:8px}.control.density{display:none}.list .card{display:block;padding:0}.list .pic{width:100%;height:auto;aspect-ratio:1;margin:0}.list .body{display:grid}.list .when{padding-left:0}}
+ </style>
+</head>
+<body>
+<header>
+ <h1 id="brand">Vendor · Catalog</h1><button id="filtersBtn">☰ Filters</button><span class="pill" id="count">…</span><span class="muted" id="meta">dw_unified</span>
+ <label class="control">Sort <select id="sort"><option value="newest">Newest</option><option value="color">Color</option><option value="style">Style</option><option value="sku">SKU A→Z</option><option value="title">Title A→Z</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option></select></label>
+ <label class="control density">Density <input id="density" type="range" min="3" max="12" value="6"></label><button id="view">▤ List view</button>
+</header>
+<div id="layout"><aside><button id="closeFilters">✕ Done — show products</button><input id="q" type="search" placeholder="Search title / SKU / pattern…"><div id="facets"></div><button id="clear">Clear filters</button></aside><main><div id="grid" class="grid"></div><button id="more">Load more</button><div id="empty" hidden>No products match.</div></main></div>
+<script>
+const $=s=>document.querySelector(s), grid=$('#grid');
+const state={q:'',sort:'newest',offset:0,total:0,loading:false,filters:{type:'',series:'',color:'',style:'',material:'',lifecycle:''}};
+const PAGE=120, pref='vendor-internal.'+location.hostname+'.';
+const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const fmtDate=v=>{if(!v)return 'Created time unavailable';const d=new Date(Number(v)||v);return Number.isNaN(d.getTime())?'Created time unavailable':d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});};
+const apiKey={type:'types',series:'series',color:'colors',style:'styles',material:'materials',lifecycle:'lifecycles'};
+const params=()=>{const p=new URLSearchParams({offset:state.offset,limit:PAGE,sort:state.sort});if(state.q)p.set('q',state.q);for(const[k,v]of Object.entries(state.filters))if(v)p.set(apiKey[k],v);return p;};
+function card(p){const life=String(p.lifecycle||'').toLowerCase(),created=p.created||p.updated;return `<article class="card"><div class="pic">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.title)}">`:''}${p.images&&p.images[1]?`<img class="alt" loading="lazy" src="${esc(p.images[1])}" alt="">`:''}</div><div class="body"><div class="title">${esc(p.title||p.pattern||p.sku||'(untitled)')}</div><div class="sku">${esc(p.sku||'—')}${p.mfr_number?' · '+esc(p.mfr_number):''}</div><div class="chips"><span class="chip ${life.includes('active')?'active':life.includes('staged')?'staged':''}">${esc(p.lifecycle||p.status||'Unknown')}</span>${(p.styles||[]).slice(0,2).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}${(p.colors||[]).slice(0,1).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}</div><time class="when" datetime="${created?new Date(Number(created)||created).toISOString():''}" title="${created?new Date(Number(created)||created).toISOString():''}">🕓 ${fmtDate(created)}</time><div class="actions">${p.internal_url?`<a href="${esc(p.internal_url)}" target="_blank" rel="noopener">Shopify admin ↗</a>`:''}${p.url?`<a href="${esc(p.url)}" target="_blank" rel="noopener">Storefront ↗</a>`:''}</div></div></article>`;}
+let requestSeq=0;
+async function load(reset=false){if(state.loading&&!reset)return;const seq=++requestSeq;state.loading=true;if(reset){state.offset=0;grid.innerHTML='';}try{const r=await fetch('/api/products?'+params());if(!r.ok)throw Error('HTTP '+r.status);const d=await r.json();if(seq!==requestSeq)return;state.total=d.total;grid.insertAdjacentHTML('beforeend',d.rows.map(card).join(''));state.offset+=d.rows.length;$('#count').textContent=d.total.toLocaleString()+' products';$('#meta').textContent=state.offset.toLocaleString()+' shown · dw_unified';$('#more').hidden=state.offset>=d.total;$('#empty').hidden=d.total!==0;}catch(e){if(seq===requestSeq)$('#meta').textContent='Catalog error: '+e.message;}finally{if(seq===requestSeq)state.loading=false;}}
+async function loadFacets(){const f=await fetch('/api/facets').then(r=>r.json());const defs=[['type','Types'],['series','Books / series'],['color','Colors'],['style','Styles'],['material','Materials'],['lifecycle','Lifecycle']];$('#facets').innerHTML=defs.map(([k,label])=>`<details><summary>${label}</summary><div class="facet">${Object.entries(f[k]||{}).slice(0,40).map(([v,n])=>`<button class="frow${state.filters[k]===v?' on':''}" data-k="${k}" data-v="${esc(v)}"><span>${esc(v)}</span><span>${Number(n).toLocaleString()}</span></button>`).join('')}</div></details>`).join('');}
+async function boot(){const c=await fetch('/api/vendor-context').then(r=>r.json());document.title=c.vendor+' · Internal Catalog · dw_unified';$('#brand').textContent=c.vendor+' · Catalog';state.sort=localStorage.getItem(pref+'sort')||'newest';$('#sort').value=state.sort;const den=localStorage.getItem(pref+'density')||'6';$('#density').value=den;document.documentElement.style.setProperty('--cols',den);if(localStorage.getItem(pref+'view')==='list'){grid.classList.add('list');$('#view').textContent='▦ Grid view';}await Promise.all([loadFacets(),load(true)]);}
+$('#facets').onclick=e=>{const b=e.target.closest('.frow');if(!b)return;state.filters[b.dataset.k]=state.filters[b.dataset.k]===b.dataset.v?'':b.dataset.v;loadFacets();load(true);};
+$('#clear').onclick=()=>{state.q='';$('#q').value='';for(const k in state.filters)state.filters[k]='';loadFacets();load(true);};let qt;$('#q').oninput=()=>{clearTimeout(qt);qt=setTimeout(()=>{state.q=$('#q').value.trim();load(true);},250);};
+$('#sort').onchange=()=>{state.sort=$('#sort').value;localStorage.setItem(pref+'sort',state.sort);load(true);};$('#density').oninput=()=>{document.documentElement.style.setProperty('--cols',$('#density').value);localStorage.setItem(pref+'density',$('#density').value);};
+$('#view').onclick=()=>{grid.classList.toggle('list');localStorage.setItem(pref+'view',grid.classList.contains('list')?'list':'grid');$('#view').textContent=grid.classList.contains('list')?'▦ Grid view':'▤ List view';};$('#more').onclick=()=>load();addEventListener('scroll',()=>{if(innerHeight+scrollY>document.body.offsetHeight-700)load();});
+$('#filtersBtn').onclick=()=>document.querySelector('aside').classList.add('open');$('#closeFilters').onclick=()=>document.querySelector('aside').classList.remove('open');boot();
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 3d765f3..67d9c1c 100644
--- a/server.js
+++ b/server.js
@@ -14,7 +14,7 @@ const { execFile } = require('child_process');
const { Pool } = require('pg');
try { require('dotenv').config(); } catch {}
const { crawlMicrosites, OUT: MICROSITES } = require('./scripts/crawl-microsites');
-const { buildVendorHostMap, vendorHostContext } = require('./lib/vendor-host');
+const { buildVendorHostMap, vendorHostContext, vendorRootFile } = require('./lib/vendor-host');
const PORT = process.env.PORT || 9958;
const PUB = path.join(__dirname, 'public');
@@ -1649,6 +1649,15 @@ const server = http.createServer((req, res) => {
return;
}
+ if (u.pathname === '/api/vendor-context' && vendorHost) {
+ const source = vendorHost.internal ? ROWS : ROWS_PUBLIC_ACTIVE;
+ const total = source.reduce((n, row) => n + (row.vendor === vendorHost.vendor ? 1 : 0), 0);
+ return sendJSON(res, 200, {
+ vendor: vendorHost.vendor, slug: vendorHost.slug, internal: vendorHost.internal,
+ total, loaded_at: LOADED_AT,
+ });
+ }
+
// ── INTERNAL full-catalog feed ─────────────────────────────────────────────────
// Auth-gated (the whole server is). The single source of truth the substitute + photo
// apps consume so they see EVERY non-archived SKU in dw_unified — not just their own line —
@@ -1719,7 +1728,7 @@ const server = http.createServer((req, res) => {
}
// static, path-traversal-safe
- const p = u.pathname === '/' ? '/index.html' : u.pathname;
+ const p = vendorRootFile(vendorHost, u.pathname) || (u.pathname === '/' ? '/index.html' : u.pathname);
const file = path.join(PUB, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
if (!file.startsWith(PUB) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
diff --git a/test/vendor-host.test.js b/test/vendor-host.test.js
index 528027b..70d9ffe 100644
--- a/test/vendor-host.test.js
+++ b/test/vendor-host.test.js
@@ -2,7 +2,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
-const { buildVendorHostMap, vendorHostContext } = require('../lib/vendor-host');
+const { buildVendorHostMap, vendorHostContext, vendorRootFile } = require('../lib/vendor-host');
const hosts = buildVendorHostMap([{ name: 'Farrow & Ball' }, { name: 'Astek' }], []);
@@ -23,3 +23,12 @@ test('unknown wildcard hosts are explicit non-vendors', () => {
test('all.dw remains outside vendor fallback routing', () => {
assert.equal(vendorHostContext('all.designerwallcoverings.com', hosts), null);
});
+
+test('internal vendor roots use the shared full catalog build', () => {
+ const internal = vendorHostContext('astek-internal.designerwallcoverings.com', hosts);
+ const publicSite = vendorHostContext('astek.designerwallcoverings.com', hosts);
+ assert.equal(vendorRootFile(internal, '/'), '/vendor-internal.html');
+ assert.equal(vendorRootFile(internal, '/index.html'), '/vendor-internal.html');
+ assert.equal(vendorRootFile(internal, '/api/products'), null);
+ assert.equal(vendorRootFile(publicSite, '/'), null);
+});
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
index b98132b..2a0bd1d 100644
--- a/verification/e2e-proof.json
+++ b/verification/e2e-proof.json
@@ -45,6 +45,36 @@
"origin_health": "pass",
"edge_auth_gate": "pass"
},
+ "internal_catalog_build": {
+ "deployed_at": "2026-08-28T17:31:00.317Z",
+ "scope": "shared Astek-style catalog shell for all eligible vendor-internal fallback hosts",
+ "features": [
+ "vendor branding",
+ "search",
+ "facets",
+ "persistent sort and density",
+ "grid and list views",
+ "created date and time",
+ "Shopify admin links",
+ "server-side pagination",
+ "latest-request race protection"
+ ],
+ "representative_edge_checks": {
+ "charles-burger-internal": { "status": 200, "products": 83 },
+ "grasscloth-internal": { "status": 200, "products": 5 },
+ "lulu-dk-internal": { "status": 200, "products": 0 },
+ "anonymous_internal": 401,
+ "unknown_internal": 404,
+ "astek_dedicated_build_preserved": true
+ },
+ "cross_browser": {
+ "chromium": "PASS",
+ "webkit": "PASS",
+ "console_errors": 0,
+ "report": "cta/CTA-REPORT.md",
+ "results": "cta/results.json"
+ }
+ },
"production_verification_pending": false,
"pending_reason": null
}
← 76b09d5 auto-data-snapshot: 2026-08-28T10:16:39 (2 data files) — cta
·
back to All Designerwallcoverings
·
crawl-microsites: serialize seedFromPm2 pm2 jlist via shared 94b6673 →