[object Object]

← back to All Designerwallcoverings

internal: expand per-card Internal into Admin / Line Viewer / Check Stock grouped control

9ca2518307a7cac9e1717a5715d2c1d1c9a9adb2 · 2026-07-07 07:37:03 -0700 · Steve

Server resolves two new public-safe side-channels per SKU: line_viewer_url (the
vendor's live microsite from the crawl artifact = its internal astek-style line
viewer) and stock (the FREE stored /livestock snapshot — in_stock/qty/lead/as-of
from stock_checks ∪ every *_catalog in_stock/last_scraped; cost & price never
selected). Grid card + list row now show Website + an 'Internal ▾' cluster that
expands inline to Shopify Admin / Line Viewer / Check Stock; each dims when its
target can't resolve; Check Stock shows the stored snapshot inline (never a metered
--live scrape on render). stopPropagation preserved so the card's website-open click
never fires from the cluster.

Files touched

Diff

commit 9ca2518307a7cac9e1717a5715d2c1d1c9a9adb2
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 07:37:03 2026 -0700

    internal: expand per-card Internal into Admin / Line Viewer / Check Stock grouped control
    
    Server resolves two new public-safe side-channels per SKU: line_viewer_url (the
    vendor's live microsite from the crawl artifact = its internal astek-style line
    viewer) and stock (the FREE stored /livestock snapshot — in_stock/qty/lead/as-of
    from stock_checks ∪ every *_catalog in_stock/last_scraped; cost & price never
    selected). Grid card + list row now show Website + an 'Internal ▾' cluster that
    expands inline to Shopify Admin / Line Viewer / Check Stock; each dims when its
    target can't resolve; Check Stock shows the stored snapshot inline (never a metered
    --live scrape on render). stopPropagation preserved so the card's website-open click
    never fires from the cluster.
---
 public/index.html | 83 +++++++++++++++++++++++++++++++++++++++++++------------
 server.js         | 78 +++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 141 insertions(+), 20 deletions(-)

diff --git a/public/index.html b/public/index.html
index bb5c6b9..fbfc2d5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -76,21 +76,36 @@
   .when { margin-top:5px; font-size:calc(10px*var(--fs)); color:var(--mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
   .when b { font-weight:700; letter-spacing:.03em; }
   .when.life-active b { color:#7fc98a; } .when.life-staged b { color:#c9dae8; } .when.life-archived b { color:#e0b3b3; }
-  /* per-card Website + Internal buttons — always resolve to the right link or disable */
-  .acts { display:flex; gap:5px; padding:0 calc(9px*var(--fs)) calc(9px*var(--fs)); margin-top:auto; }
-  .acts a { flex:1; text-align:center; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 5px; border-radius:5px;
+  /* per-card Website pill + grouped "Internal ▾" cluster (Admin / Line Viewer / Check Stock).
+     Every target resolves to a real link/panel or the control dims — never a dead control. */
+  .acts { display:flex; flex-wrap:wrap; gap:5px; padding:0 calc(9px*var(--fs)) calc(9px*var(--fs)); margin-top:auto; }
+  .acts a.web, .acts .intbtn { flex:1; text-align:center; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 5px; border-radius:5px;
     text-decoration:none; border:1px solid var(--line); cursor:pointer; white-space:nowrap; letter-spacing:.02em; }
   .acts a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
   .acts a.web:hover { filter:brightness(1.08); }
-  .acts a.int { background:transparent; color:var(--accent); }
-  .acts a.int:hover { border-color:var(--accent); }
-  .acts a.disabled { opacity:.3; pointer-events:none; background:transparent; color:var(--mut); border-color:var(--line); }
-  /* list-view compact link buttons */
+  .acts .intbtn { background:transparent; color:var(--accent); font-family:inherit; }
+  .acts .intbtn:hover { border-color:var(--accent); }
+  .acts a.web.disabled { opacity:.3; pointer-events:none; background:transparent; color:var(--mut); border-color:var(--line); }
+  /* expanding internal panel — inline so nothing is clipped by the card's overflow */
+  .intpanel { flex-basis:100%; width:100%; display:flex; flex-direction:column; gap:4px; margin-top:5px;
+    padding:6px; border:1px solid var(--line); border-radius:6px; background:#0f0f13; }
+  .intpanel[hidden] { display:none; }
+  .im { display:block; text-align:left; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 7px; border-radius:5px;
+    text-decoration:none; border:1px solid var(--line); background:var(--card); color:var(--accent); cursor:pointer; font-family:inherit; }
+  .im:hover { border-color:var(--accent); }
+  .im.disabled { opacity:.35; pointer-events:none; color:var(--mut); border-color:var(--line); }
+  /* stored-stock popover ($0 local — never a --live portal scrape) */
+  .stockpop { margin-top:2px; padding:6px 8px; border-radius:5px; background:#15170f; border:1px solid #34381f; font-size:calc(10.5px*var(--fs)); }
+  .stockpop[hidden] { display:none; }
+  .stockpop .sdot.in { color:#7fc98a; } .stockpop .sdot.out { color:#e08b8b; }
+  .stockpop .sline { color:var(--txt); margin-top:2px; } .stockpop .sline.mut { color:var(--mut); }
+  /* list-view compact link cluster */
   td.acts-td { white-space:nowrap; }
-  td.acts-td a { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin-right:4px; }
+  td.acts-td a, td.acts-td .intbtn { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin:0 4px 3px 0; background:transparent; color:var(--accent); cursor:pointer; font-family:inherit; }
   td.acts-td a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
-  td.acts-td a.int { color:var(--accent); }
-  td.acts-td a.disabled { opacity:.3; pointer-events:none; color:var(--mut); }
+  td.acts-td .im { display:inline-block; margin:0 4px 0 0; }
+  td.acts-td a.disabled, td.acts-td .im.disabled { opacity:.35; pointer-events:none; color:var(--mut); }
+  td.acts-td .intpanel { width:auto; margin-top:5px; }
   /* CARD FIELDS visibility toggles — all off = pure image-only texture wall */
   .grid.h-sku .sku, .grid.h-ttl .ttl, .grid.h-vt .vt, .grid.h-fac .fac, .grid.h-price .price, .grid.h-when .when, .grid.h-act .acts { display:none; }
   .grid.h-sku.h-ttl.h-vt.h-fac.h-price.h-when .meta { display:none; }
@@ -427,16 +442,48 @@ function whenChip(r) { // returns { html, textOnly } for grid card / list cell
   };
 }
 
-// Website → customer-facing storefront page; Internal → Shopify admin product view.
-// Each degrades to a disabled pill (never a dead link) when its URL can't resolve.
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+// Stored-stock popover — the FREE /livestock snapshot ($0 local). Public-safe availability
+// only (in stock / qty / lead / as-of); NEVER a metered --live portal scrape on render.
+function stockPopHTML(st) {
+  if (!st) return '';
+  const dot = st.in_stock
+    ? '<span class="sdot in">●</span> In stock'
+    : '<span class="sdot out">●</span> Out of stock';
+  const bits = [];
+  if (st.quantity != null && st.quantity !== '') bits.push('Qty ' + esc(st.quantity));
+  if (st.lead_time) bits.push('Lead ' + esc(st.lead_time));
+  const when = st.as_of ? 'as of ' + esc(st.as_of) : 'stored snapshot';
+  const src = st.source ? ' · ' + esc(st.source) : '';
+  return `<div class="stockpop" hidden>${dot}${bits.length ? '<div class="sline">' + bits.join(' · ') + '</div>' : ''}<div class="sline mut">${when}${src}</div></div>`;
+}
+
+// Website pill (unchanged) + a compact grouped "Internal ▾" cluster: Admin, Line Viewer,
+// Check Stock. Every target resolves to a real link/panel or dims — never a dead control.
 function actButtonsHTML(r) {
   const web = r.url
     ? `<a class="web" href="${r.url}" target="_blank" rel="noopener noreferrer">Website</a>`
     : `<a class="web disabled" title="Not live on the storefront yet">Website</a>`;
-  const int = r.internal_url
-    ? `<a class="int" href="${r.internal_url}" target="_blank" rel="noopener noreferrer">Internal</a>`
-    : `<a class="int disabled" title="No internal page for this SKU">Internal</a>`;
-  return web + int;
+  const admin = r.internal_url
+    ? `<a class="im" href="${r.internal_url}" target="_blank" rel="noopener noreferrer">Shopify Admin ↗</a>`
+    : `<a class="im disabled" title="No Shopify admin id for this SKU">Shopify Admin</a>`;
+  const viewer = r.line_viewer_url
+    ? `<a class="im" href="${r.line_viewer_url}" target="_blank" rel="noopener noreferrer">Line Viewer ↗</a>`
+    : `<a class="im disabled" title="No internal line viewer for this line yet">Line Viewer</a>`;
+  const stock = r.stock
+    ? `<button class="im stockbtn" type="button">Check Stock</button>`
+    : `<button class="im disabled" type="button" title="No stored stock snapshot — run /livestock ${esc(r.sku || '')}">Check Stock</button>`;
+  return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${stock}${stockPopHTML(r.stock)}</div>`;
+}
+
+// Delegated within an .acts container (which stops propagation so the card's own
+// open-website click never fires): toggle the Internal panel + the stock popover.
+function handleActsClick(e) {
+  const intbtn = e.target.closest('.intbtn');
+  if (intbtn) { const p = intbtn.parentElement.querySelector('.intpanel'); if (p) { p.hidden = !p.hidden; intbtn.textContent = p.hidden ? 'Internal ▾' : 'Internal ▴'; } return; }
+  const sb = e.target.closest('.stockbtn');
+  if (sb) { const pop = sb.closest('.intpanel').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
 }
 
 function card(r) {
@@ -449,7 +496,7 @@ function card(r) {
   const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
   const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
   el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
-  const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => e.stopPropagation());
+  const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
   if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
   return el;
 }
@@ -472,7 +519,7 @@ function listRow(r) {
   tr.appendChild(cell(r.status || ''));
   tr.appendChild(cell(r.created ? `<span title="${new Date(r.created).toISOString()}">${fmtDate(r.created)}</span>` : ''));
   const actTd = cell(actButtonsHTML(r), 'acts-td');
-  actTd.addEventListener('click', (e) => e.stopPropagation());
+  actTd.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
   tr.appendChild(actTd);
   if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
   return tr;
diff --git a/server.js b/server.js
index ba0eb42..343623d 100644
--- a/server.js
+++ b/server.js
@@ -58,6 +58,10 @@ const ADMIN_STORE = process.env.SHOPIFY_ADMIN_STORE || 'designer-laboratory-sand
 // Deterministic, in-RAM, and never leaks an upstream/private-label name (it's a house code).
 const seriesOf = (sku) => { const m = (sku || '').match(/^[A-Za-z]{2,}/); return m ? m[0].toUpperCase() : null; };
 const adminUrl = (gid) => { const m = (gid || '').match(/(\d+)\s*$/); return m ? `https://admin.shopify.com/store/${ADMIN_STORE}/products/${m[1]}` : null; };
+// Same slugify the crawler uses — a vendor's live microsite host is its INTERNAL line viewer.
+const slugify = (v) => String(v || '').toLowerCase().normalize('NFD')
+  .replace(/[̀-ͯ]/g, '').replace(/&/g, ' and ')
+  .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
 
 // HARD front-facing rule (same list build-data.py enforced for the vendor directory):
 // banned upstream/private-label source names never ship on this public surface — rows
@@ -89,6 +93,67 @@ async function snapSql() {
 let ROWS = [];
 let LOADED_AT = null;
 let pool = null;
+let STOCK = new Map();          // upper(sku) → public-safe stored stock snapshot (the /livestock FREE path)
+let VENDOR_VIEWER = new Map();  // slugify(vendor) / slug → internal line-viewer URL (the vendor's live microsite)
+
+// Stored stock snapshot — the FREE /livestock path. PUBLIC-SAFE availability ONLY
+// (in_stock / quantity / lead_time / as-of); cost & price columns are NEVER selected.
+// Sources: the dedicated stock_checks cache ∪ every *_catalog table's in_stock/last_scraped.
+// Loaded into RAM on the snapshot cycle so a card-click never scrapes and never hits a portal.
+async function loadStock() {
+  const map = new Map();
+  const put = (key, val) => {
+    if (!key) return;
+    const k = String(key).toUpperCase();
+    const prev = map.get(k);
+    if (!prev || (val.as_of || '') > (prev.as_of || '')) map.set(k, val); // newest reading wins
+  };
+  // 1. per-vendor catalog snapshots (broad coverage) — table names come from the DB
+  //    catalog (not user input) and are allow-listed to ^[a-z0-9_]+_catalog$.
+  const { rows: cols } = await pool.query(
+    `SELECT table_name, column_name FROM information_schema.columns
+      WHERE table_schema='public' AND table_name ~ '^[a-z0-9_]+_catalog$'
+        AND column_name IN ('dw_sku','in_stock','last_scraped')`);
+  const byTab = {};
+  for (const c of cols) (byTab[c.table_name] ||= new Set()).add(c.column_name);
+  const parts = [];
+  for (const [t, set] of Object.entries(byTab)) {
+    if (!set.has('dw_sku') || !set.has('in_stock')) continue;
+    const asof = set.has('last_scraped') ? 'last_scraped::text' : 'NULL::text';
+    parts.push(`SELECT dw_sku, in_stock::text AS in_stock, ${asof} AS as_of FROM "${t}" WHERE in_stock IS NOT NULL AND dw_sku IS NOT NULL`);
+  }
+  if (parts.length) {
+    const { rows } = await pool.query(parts.join(' UNION ALL '));
+    for (const r of rows) put(r.dw_sku, { in_stock: r.in_stock === 'true' || r.in_stock === 't', as_of: r.as_of ? r.as_of.slice(0, 10) : null, source: 'catalog' });
+  }
+  // 2. the dedicated stock-check cache (also carries quantity + lead time), keyed both ways
+  try {
+    const { rows } = await pool.query(
+      `SELECT dw_sku, mfr_sku, in_stock, quantity, lead_time, checked_at::text AS as_of, source FROM stock_checks`);
+    for (const r of rows) {
+      const val = { in_stock: r.in_stock, quantity: r.quantity, lead_time: r.lead_time || null, as_of: r.as_of ? r.as_of.slice(0, 10) : null, source: r.source || 'stock_check' };
+      put(r.dw_sku, val); put(r.mfr_sku, val);
+    }
+  } catch { /* stock_checks optional */ }
+  STOCK = map;
+}
+
+// Internal line viewer per product line = the vendor's live microsite (astek-landing style).
+// Resolved from the crawl artifact so ANY microsite that exists resolves with no code change;
+// vendors without a live line viewer degrade gracefully (the control dims).
+function buildVendorViewer() {
+  const map = new Map();
+  try {
+    const d = JSON.parse(fs.readFileSync(MICROSITES, 'utf8'));
+    for (const s of (d.sites || [])) {
+      if (!s.up || !s.host || BANNED.test(s.host)) continue;
+      const url = `https://${s.host}/`;
+      if (s.vendor && !BANNED.test(s.vendor)) map.set(slugify(s.vendor), url);
+      if (s.slug) map.set(String(s.slug).toLowerCase(), url);
+    }
+  } catch { /* crawl artifact not ready yet — viewers resolve after the first crawl lands */ }
+  VENDOR_VIEWER = map;
+}
 
 function deriveRow(r) {
   const tags = (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean);
@@ -128,8 +193,13 @@ function deriveRow(r) {
     image_state: r.image_url ? 'Has image' : 'No image',
     // Website = customer-facing storefront page (only when actually live on the Online Store).
     url: (r.online_store_published && r.handle) ? `${STOREFRONT}/products/${r.handle}` : null,
-    // Internal = Shopify admin product view (resolves for any row that has a shopify_id GID).
+    // Internal cluster — three destinations per SKU, each resolves or the control dims:
+    //   Admin       = Shopify admin product view (any row with a shopify_id GID),
+    //   Line Viewer = the vendor's live internal microsite (astek-landing style),
+    //   stock       = the FREE stored /livestock snapshot (public-safe availability only).
     internal_url: adminUrl(r.shopify_id),
+    line_viewer_url: r.vendor ? (VENDOR_VIEWER.get(slugify(r.vendor)) || null) : null,
+    stock: (sku && STOCK.get(String(sku).toUpperCase())) || null,
     created: r.created_at_shopify ? new Date(r.created_at_shopify).getTime() : 0,
     updated: r.updated_at_shopify ? new Date(r.updated_at_shopify).getTime() : 0,
     // one lowercase haystack per row so search is a single .includes() pass
@@ -140,12 +210,16 @@ function deriveRow(r) {
 async function loadSnapshot() {
   if (!pool) pool = new Pool({ connectionString: DSN, max: 2 });
   const t0 = Date.now();
+  // Resolve the two side-channels BEFORE mapping rows (both non-fatal — a slow/empty
+  // stock or viewer map just dims those controls, it never takes the grid down).
+  buildVendorViewer();
+  try { await loadStock(); } catch (e) { console.error('stock load failed (non-fatal):', e.message); }
   const res = await pool.query(await snapSql());
   ROWS = res.rows
     .filter((r) => !BANNED.test(r.vendor || '') && !BANNED.test(r.title || ''))
     .map(deriveRow);
   LOADED_AT = new Date().toISOString();
-  console.log(`snapshot: ${ROWS.length.toLocaleString()} products in ${Date.now() - t0}ms`);
+  console.log(`snapshot: ${ROWS.length.toLocaleString()} products in ${Date.now() - t0}ms (stock ${STOCK.size.toLocaleString()} · viewers ${VENDOR_VIEWER.size})`);
 }
 
 // ── filtering / sorting (japan-viewer pattern) ──────────────────────────────────

← a5f3545 grid: Book/Series facet in left rail + Website/Internal butt  ·  back to All Designerwallcoverings  ·  all-dw chips: add 'This Item ↗' link — internal microsite pr ef3b79c →