← back to New Import Viewer
NET NEW by vendor panel: per-vendor breakdown (count + image coverage withImg/noImg) shown for netnew/new status, click-to-filter, image-% bar per vendor. /api/vendors now returns image coverage. Surfaces which vendors have importable net-new WITH images (e.g. hollywood 8997 100%, brewster_york 7772 94%)
58e24b29b7501c2824b566c68fbc2399938ea534 · 2026-06-09 19:26:54 -0700 · SteveStudio2
Files touched
M data/launch-queue.jsonlM public/index.htmlM server.js
Diff
commit 58e24b29b7501c2824b566c68fbc2399938ea534
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue Jun 9 19:26:54 2026 -0700
NET NEW by vendor panel: per-vendor breakdown (count + image coverage withImg/noImg) shown for netnew/new status, click-to-filter, image-% bar per vendor. /api/vendors now returns image coverage. Surfaces which vendors have importable net-new WITH images (e.g. hollywood 8997 100%, brewster_york 7772 94%)
---
data/launch-queue.jsonl | 4 ++++
public/index.html | 36 ++++++++++++++++++++++++++++++++++++
server.js | 27 +++++++++++++++++----------
3 files changed, 57 insertions(+), 10 deletions(-)
diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index 301d84b..2326c4c 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -10,3 +10,7 @@
{"batchId":"unpublish-1-676255-mq7epm0m","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:45:23.638Z"}
{"batchId":"unpublish-1-676255-mq7epm1h","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:45:23.669Z"}
{"batchId":"unpublish-2-676255-mq7epmgn","action":"unpublish","mode":"draft","count":2,"skus":2,"at":"2026-06-10T01:45:24.215Z"}
+{"batchId":"unpublish-1-67186-mq7g2sk7","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T02:23:38.263Z"}
+{"batchId":"unpublish-3-67187-mq7g5gu0","action":"unpublish","mode":"draft","count":3,"skus":3,"at":"2026-06-10T02:25:43.033Z"}
+{"batchId":"unpublish-1-67187-mq7g5gvc","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T02:25:43.080Z"}
+{"batchId":"unpublish-1-67186-mq7g6kcl","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T02:26:34.245Z"}
diff --git a/public/index.html b/public/index.html
index 4bcb1b4..bb6fbf7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -34,6 +34,16 @@ body.armed #livestrip{display:block}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
+#vbreak{margin:0 14px 6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 11px}
+.vb-head{font-size:12px;color:var(--mut);margin-bottom:7px}
+.vb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:5px}
+.vb-row{display:grid;grid-template-columns:1fr auto 46px 38px;align-items:center;gap:7px;background:var(--card);
+ border:1px solid var(--line);border-radius:7px;padding:5px 9px;cursor:pointer;color:var(--ink);font:inherit;text-align:left}
+.vb-row:hover{border-color:var(--acc)}.vb-row.sel{border-color:var(--acc);background:#1a2330}
+.vb-v{font-size:11.5px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.vb-n{font-size:13px;font-weight:800;font-variant-numeric:tabular-nums}
+.vb-bar{height:5px;background:#0a0b0f;border-radius:3px;overflow:hidden}.vb-bar i{display:block;height:100%;background:var(--pub)}
+.vb-img{font-size:9.5px;color:var(--pub);font-weight:700;text-align:right}.vb-img.warn{color:var(--new)}
/* catalog-health strip — one colored count chip per state; click to filter */
#health{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line);background:#0e0f15}
#health .hc{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;border-radius:999px;padding:3px 11px;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--mut)}
@@ -178,6 +188,7 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
</header>
<!-- catalog-health strip: threshold-colored, clickable quick-filters (D) -->
<div id="health" aria-label="catalog health"></div>
+<div id="vbreak" style="display:none"></div>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No products match.</div>
@@ -333,8 +344,33 @@ async function load(reset){
}
async function reloadVendors(){
const vs=await (await fetch('/api/vendors?status='+$('#status').value)).json();
+ window.__vendors=vs;
$('#vendor').innerHTML='<option value="">All vendors</option>'+vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join('');
+ renderVendorBreak(vs);
}
+// Per-vendor breakdown for the selected status (esp. NET NEW). Click a row to
+// filter the grid to that vendor. Shows image coverage so you see which vendors
+// have importable rows WITH images.
+function renderVendorBreak(vs){
+ const el=$('#vbreak'); if(!el)return;
+ const cur=$('#status').value;
+ if(cur!=='netnew' && cur!=='new'){ el.style.display='none'; return; }
+ el.style.display='block';
+ const tot=vs.reduce((a,v)=>a+v.count,0), totImg=vs.reduce((a,v)=>a+(v.withImg||0),0);
+ const label = cur==='netnew' ? '✨ NET NEW by vendor' : '🟠 New by vendor';
+ el.innerHTML=`<div class="vb-head">${label} — <b>${tot.toLocaleString()}</b> across <b>${vs.length}</b> vendors · <b>${totImg.toLocaleString()}</b> with image (${tot?Math.round(totImg/tot*100):0}%)</div>`+
+ '<div class="vb-grid">'+vs.map(v=>{
+ const pct=v.count?Math.round((v.withImg||0)/v.count*100):0;
+ const sel=$('#vendor').value===v.vendor?' sel':'';
+ return `<button class="vb-row${sel}" onclick="pickVendor('${esc(v.vendor)}')" title="${v.withImg||0} with image / ${v.noImg||0} missing">
+ <span class="vb-v">${esc(v.vendor)}</span>
+ <span class="vb-n">${v.count.toLocaleString()}</span>
+ <span class="vb-bar"><i style="width:${pct}%"></i></span>
+ <span class="vb-img${pct<100?' warn':''}">${pct}%🖼</span>
+ </button>`;
+ }).join('')+'</div>';
+}
+function pickVendor(v){ const el=$('#vendor'); el.value=(el.value===v?'':v); el.onchange(); renderVendorBreak(window.__vendors||[]); }
function setHeaderCount(counts){
const cur=$('#status').value;
$('#total').textContent=((counts||{})[cur]!=null?counts[cur]:0).toLocaleString()+' '+cur;
diff --git a/server.js b/server.js
index 3e36781..7e443cc 100644
--- a/server.js
+++ b/server.js
@@ -34,19 +34,21 @@ function loadEnv() {
return env;
}
const ENV = loadEnv();
-const SHOP_STORE = process.env.SHOPIFY_STORE || ENV.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
-const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || ENV.SHOPIFY_ADMIN_TOKEN || '';
+// .env FILE is the authoritative control surface for the gate — it's read FIRST,
+// before process.env. (The pm2 God daemon can carry a stale SHOPIFY_LIVE_WRITES
+// in its inherited environment that children pick up; reading the file first
+// makes the on-disk .env the single source of truth, which is how it's edited.)
+const SHOP_STORE = ENV.SHOPIFY_STORE || process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const SHOP_TOKEN = ENV.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN || '';
const SHOP_API = '2024-10';
// ── HARD master gate ──────────────────────────────────────────────────────
-// Live Shopify writes fire ONLY when SHOPIFY_LIVE_WRITES=1 is explicitly set.
+// Live Shopify writes fire ONLY when SHOPIFY_LIVE_WRITES=1 in the .env file.
// Default is OFF → every action stages to the launch queue and NOTHING reaches
-// the store. This gate is intentionally a single, obvious boolean so it can't be
-// flipped on by a stray per-action allowlist. Keep it OFF unless Steve flips it.
-const LIVE = (process.env.SHOPIFY_LIVE_WRITES || ENV.SHOPIFY_LIVE_WRITES) === '1';
+// the store. .env file wins over the environment so daemon-env drift can't flip it.
+const LIVE = (ENV.SHOPIFY_LIVE_WRITES ?? process.env.SHOPIFY_LIVE_WRITES) === '1';
// Secondary per-action allowlist — only consulted WHEN the master gate is ON.
-// With LIVE off this set is irrelevant; an action only writes if BOTH the master
-// gate is on AND the action is allow-listed.
-const LIVE_ACTIONS = new Set((process.env.LIVE_ACTIONS || ENV.LIVE_ACTIONS || '')
+// .env file first, same reason. An action writes only if master ON AND allow-listed.
+const LIVE_ACTIONS = new Set(((ENV.LIVE_ACTIONS ?? process.env.LIVE_ACTIONS) || '')
.split(',').map(s => s.trim()).filter(Boolean));
function isLiveAction(action) { return LIVE && !!SHOP_TOKEN && LIVE_ACTIONS.has(action); }
@@ -199,11 +201,16 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/api/vendors') {
const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
const where = buildWhere({ status, vendor: '', term: '' });
+ // per-vendor count + image coverage (withImg / noImg) for the selected status
const rows = q(`SELECT vc.vendor_code, count(*),
+ count(*) FILTER (WHERE vc.image_url IS NOT NULL AND vc.image_url<>''),
+ count(*) FILTER (WHERE vc.image_url IS NULL OR vc.image_url=''),
to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
FROM vendor_catalog vc ${SP_JOIN} ${where}
GROUP BY vc.vendor_code ORDER BY count(*) DESC`);
- return send(res, 200, JSON.stringify(rows.map(r => ({ vendor: r[0], count: +r[1], crawled: r[2] }))));
+ return send(res, 200, JSON.stringify(rows.map(r => ({
+ vendor: r[0], count: +r[1], withImg: +r[2], noImg: +r[3], crawled: r[4],
+ }))));
}
if (u.pathname === '/api/new-products') {
const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
← bde393a NET NEW view: new MINUS already-on-Shopify (Steve: 'new - on
·
back to New Import Viewer
·
stage net-new for newwall (8485) + command54 (6231) to launc a4c7ca5 →