[object Object]

← back to All Designerwallcoverings

build full internal catalog for every unified vendor

c1420120edf33624a2ae64f8085f1149956eb43d · 2026-08-28 10:31:45 -0700 · Steve Abrams

Files touched

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 →