[object Object]

← back to Japan Enrich

viewer: host-based scoping — china.=Greenland-only (empty state for now), japan.=Sangetsu+Lilycolor unchanged

df5e3a0f005b4f46852efca5b2de984c74c8e763 · 2026-06-30 14:41:13 -0700 · Steve Abrams

server.js: per-request hostScope(req) reads Host header; china.* → source:'greenland' pool only, japan/local → lily+sangetsu (greenland excluded). Added /api/scope endpoint; /api/skus + /api/facets pre-filter ROWS by host pool and clamp f.source to in-scope sources so neither host can leak into the other. index.html fetches /api/scope first to set title/H1/subtitle + build source tabs (china: All+Greenland; japan: All+Lilycolor+Sangetsu) and shows a 'Greenland — catalog coming soon' empty state.

Files touched

Diff

commit df5e3a0f005b4f46852efca5b2de984c74c8e763
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 14:41:13 2026 -0700

    viewer: host-based scoping — china.=Greenland-only (empty state for now), japan.=Sangetsu+Lilycolor unchanged
    
    server.js: per-request hostScope(req) reads Host header; china.* → source:'greenland' pool only, japan/local → lily+sangetsu (greenland excluded). Added /api/scope endpoint; /api/skus + /api/facets pre-filter ROWS by host pool and clamp f.source to in-scope sources so neither host can leak into the other. index.html fetches /api/scope first to set title/H1/subtitle + build source tabs (china: All+Greenland; japan: All+Lilycolor+Sangetsu) and shows a 'Greenland — catalog coming soon' empty state.
---
 viewer-local/public/index.html | 53 ++++++++++++++++++++++++++++++--------
 viewer-local/server.js         | 58 ++++++++++++++++++++++++++++++++----------
 2 files changed, 88 insertions(+), 23 deletions(-)

diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index a6fabb8..c2f2a33 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -3,7 +3,7 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Sangetsu + Lilycolor — Staged SKUs (offline preview)</title>
+<title>Staged SKUs (offline preview)</title>
 <style>
   :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
   * { box-sizing: border-box; }
@@ -77,12 +77,10 @@
 </head>
 <body>
 <header>
-  <h1>Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
+  <h1 id="h1">Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
   <div class="controls">
     <div class="seg" id="srcSeg">
       <button data-src="all" class="on">All</button>
-      <button data-src="lilycolor">Lilycolor</button>
-      <button data-src="sangetsu">Sangetsu</button>
     </div>
     <input type="search" id="q" placeholder="Search SKU or pattern…">
     <label class="dens">Sort
@@ -135,6 +133,9 @@ const grid = document.getElementById('grid');
 const statusEl = document.getElementById('status');
 const subEl = document.getElementById('sub');
 const LIMIT = 120;
+// host scope (set by loadScope() before first load): {id,label,sub,tabs[]}
+let SCOPE = { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
+  tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
 
 // representative dot color per family (UI only — true hex comes from each card's palette)
 const FAMILY_DOT = {
@@ -193,11 +194,34 @@ const facetsEl = document.getElementById('facets');
 facetsEl.open = localStorage.getItem('dw_facets_open') !== '0';
 facetsEl.ontoggle = () => localStorage.setItem('dw_facets_open', facetsEl.open ? '1' : '0');
 
-// restore sort/source/enriched UI
+// restore sort/enriched UI (source tabs are built by loadScope() once the host scope is known)
 document.getElementById('sort').value = state.sort;
-document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === state.source));
 document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === state.enriched));
 
+// Build the source-tab bar for the current host scope and clamp a stale persisted source.
+// china shows only [All, Greenland]; japan shows [All, Lilycolor, Sangetsu].
+function buildSrcTabs() {
+  const seg = document.getElementById('srcSeg');
+  const valid = new Set(['all', ...SCOPE.tabs.map(t => t.src)]);
+  if (!valid.has(state.source)) { state.source = 'all'; localStorage.setItem('dw_src', 'all'); }
+  seg.innerHTML = '';
+  const mk = (src, label) => { const b = document.createElement('button'); b.dataset.src = src; b.textContent = label; if (src === state.source) b.classList.add('on'); return b; };
+  seg.appendChild(mk('all', 'All'));
+  SCOPE.tabs.forEach(t => seg.appendChild(mk(t.src, t.label)));
+}
+
+async function loadScope() {
+  try {
+    const res = await fetch('/api/scope');
+    if (res.ok) SCOPE = await res.json();
+  } catch (e) { /* keep default japan scope */ }
+  document.title = SCOPE.label + ' — Staged SKUs (offline preview)';
+  const h1 = document.getElementById('h1');
+  if (h1) h1.firstChild.textContent = SCOPE.label + ' ';
+  subEl.textContent = SCOPE.sub;
+  buildSrcTabs();
+}
+
 function persistFacets() {
   localStorage.setItem('dw_colors', JSON.stringify([...state.colors]));
   localStorage.setItem('dw_styles', JSON.stringify([...state.styles]));
@@ -395,8 +419,16 @@ async function load() {
     state.offset += data.rows.length;
     state.done = state.offset >= data.total || data.rows.length === 0;
     const fxNote = FX.rate ? ` · ¥→$ ${FX.rate.toFixed(5)} ${FX.src === 'live' ? 'live' : 'approx'} (${FX.asOf})` : ' · ¥→$ …';
-    subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs (Lily ${data.counts.lilycolor.toLocaleString()} / Sangetsu ${data.counts.sangetsu.toLocaleString()}) · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
-    statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
+    // per-source breakdown built from the scope's tabs (Lily/Sangetsu for japan; Greenland for china)
+    const parts = (SCOPE.tabs || []).map(t => `${t.label} ${(data.counts[t.src] || 0).toLocaleString()}`).join(' / ');
+    const brk = parts ? ` (${parts})` : '';
+    subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs${brk} · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
+    if (data.total === 0) {
+      // graceful empty state — e.g. china before any Greenland data lands
+      statusEl.textContent = SCOPE.id === 'china' ? 'Greenland — catalog coming soon' : '— no matching SKUs —';
+    } else {
+      statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
+    }
   } catch (e) {
     statusEl.textContent = 'Load error — check server';
   } finally {
@@ -406,8 +438,9 @@ async function load() {
 
 // infinite scroll
 new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
-refreshFacets();
-loadFX().finally(load);   // pull the live JPY→USD rate first so the first cards render in $ (load runs regardless)
+// resolve the host scope FIRST (sets title/H1/subtitle + builds the source tabs), then
+// fan out facets + grid. Clamp the persisted source before any /api call goes out.
+loadScope().then(() => { refreshFacets(); loadFX().finally(load); });
 </script>
 </body>
 </html>
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 8e8002c..7081486 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -244,19 +244,50 @@ function authed(req) {
   return u === AUTH_USER && p === AUTH_PASS;
 }
 
+// ── per-host catalog scope ──────────────────────────────────────────────────────
+// china.designerwallcoverings.com → Greenland-vendor catalog ONLY (source:'greenland').
+// japan. (or anything else / localhost) → the Japanese catalog, with any greenland row
+// EXCLUDED. The scope pre-filters ROWS BEFORE any user filter and overrides f.source so
+// the two hosts can never leak into each other.
+function hostScope(req) {
+  const host = String(req.headers.host || '').toLowerCase().split(':')[0];
+  if (host.startsWith('china.')) {
+    return { id: 'china', label: 'China — Greenland', sub: 'Greenland vendor · staged offline · read-only preview',
+      sources: ['greenland'], tabs: [{ src: 'greenland', label: 'Greenland' }] };
+  }
+  // japan + local dev + anything else: the existing Japanese catalog, greenland excluded
+  return { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
+    sources: ['lilycolor', 'sangetsu'], tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
+}
+// rows visible for this host (the pool every count + filter runs against)
+const scopedRows = (scope) => ROWS.filter((r) => scope.sources.includes(r.source));
+
 const server = http.createServer((req, res) => {
   if (REQUIRE_AUTH && !authed(req)) {
     res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
     return res.end('Authentication required');
   }
   const u = new URL(req.url, `http://localhost:${PORT}`);
+  const scope = hostScope(req);
+  const POOL = scopedRows(scope);
+  // tell the client which host scope it's in so it can set the H1/subtitle + hide the
+  // irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
+  if (u.pathname === '/api/scope') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ id: scope.id, label: scope.label, sub: scope.sub, tabs: scope.tabs }));
+    return;
+  }
   if (u.pathname === '/api/skus') {
     const f = parseFilters(u);
+    // host scope overrides the user-supplied source so a stale localStorage tab can't
+    // request lilycolor on china (or greenland on japan); clamp to in-scope sources only.
+    if (!scope.sources.includes(f.source)) f.source = 'all';
     const sort = u.searchParams.get('sort') || 'newest';
     const offset = parseInt(u.searchParams.get('offset') || '0', 10);
     const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
-    let rows = applyFilters(ROWS, f);
-    const counts = { all: ROWS.length, lilycolor: ROWS.filter((r) => r.source === 'lilycolor').length, sangetsu: ROWS.filter((r) => r.source === 'sangetsu').length };
+    let rows = applyFilters(POOL, f);
+    const counts = { all: POOL.length };
+    for (const s of scope.sources) counts[s] = POOL.filter((r) => r.source === s).length;
     if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
     const page = rows.slice(offset, offset + limit);
     res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -266,24 +297,25 @@ const server = http.createServer((req, res) => {
   // facet counts — each dimension counted against the OTHER active filters (drill-down)
   if (u.pathname === '/api/facets') {
     const f = parseFilters(u);
+    if (!scope.sources.includes(f.source)) f.source = 'all';
     const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
     const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of (r[key] || [])) m[v] = (m[v] || 0) + 1; } return m; };
-    const enrRows = applyFilters(ROWS, f, { skip: 'enriched' });
+    const enrRows = applyFilters(POOL, f, { skip: 'enriched' });
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify({
-      color_family: tally(applyFilters(ROWS, f, { skip: 'color' }), 'color_family'),
-      style: tally(applyFilters(ROWS, f, { skip: 'style' }), 'style'),
-      material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
-      collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
-      width: tally(applyFilters(ROWS, f, { skip: 'width' }), 'width'),
-      fire_class: tally(applyFilters(ROWS, f, { skip: 'fire' }), 'fire_class'),
-      functions: tallyArr(applyFilters(ROWS, f, { skip: 'funcs' }), 'functions'),
-      price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
-      image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+      color_family: tally(applyFilters(POOL, f, { skip: 'color' }), 'color_family'),
+      style: tally(applyFilters(POOL, f, { skip: 'style' }), 'style'),
+      material: tally(applyFilters(POOL, f, { skip: 'material' }), 'material'),
+      collection: tally(applyFilters(POOL, f, { skip: 'collection' }), 'collection'),
+      width: tally(applyFilters(POOL, f, { skip: 'width' }), 'width'),
+      fire_class: tally(applyFilters(POOL, f, { skip: 'fire' }), 'fire_class'),
+      functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
+      price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
+      image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
       price_order: PRICE_ORDER,
       enriched: enrRows.filter((r) => r.enriched).length,
       unenriched: enrRows.filter((r) => !r.enriched).length,
-      total: applyFilters(ROWS, f).length,
+      total: applyFilters(POOL, f).length,
       family_order: FAMILY_ORDER,
     }));
     return;

← 1826109 viewer: env-gated admin Basic Auth (REQUIRE_AUTH) for deploy  ·  back to Japan Enrich  ·  greenland: scrape vendor API → 880 patterns/3117 colorways, cb79303 →