[object Object]

← back to New Import Viewer

Show full Shopify state (new/published/unpublished/archived) + staged action suite under collapsed per-card chips; indexed shopify_products join

d24c5abde1208944ccc7918f4bc8c5baf363b8ea · 2026-06-09 18:01:09 -0700 · SteveStudio2

Files touched

Diff

commit d24c5abde1208944ccc7918f4bc8c5baf363b8ea
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 18:01:09 2026 -0700

    Show full Shopify state (new/published/unpublished/archived) + staged action suite under collapsed per-card chips; indexed shopify_products join
---
 data/launch-queue.jsonl |   1 +
 public/index.html       | 201 ++++++++++++++++++++++++++++++++++--------------
 server.js               | 173 ++++++++++++++++++++++++++++-------------
 3 files changed, 265 insertions(+), 110 deletions(-)

diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index e69de29..d49d82e 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -0,0 +1 @@
+{"batchId":"unpublish-1-67187-mq7d4mkl","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:01:04.965Z"}
diff --git a/public/index.html b/public/index.html
index b472945..c6123e9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,8 +1,9 @@
 <!doctype html><html lang="en"><head><meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Products New to Import · dw_unified</title>
+<title>Catalog · Shopify state · dw_unified</title>
 <style>
-:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#181b24;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--new:#f59e0b;--line:#262a36}
+:root{--cols:6;--bg:#0c0d12;--panel:#15171f;--card:#181b24;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+ --new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--del:#6b7280;--ship:#60a5fa}
 *{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:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
@@ -14,54 +15,83 @@ select,input[type=text]{background:var(--panel);color:var(--ink);border:1px soli
 input[type=text]{min-width:180px}
 .ctl{display:flex;align-items:center;gap:7px}
 input[type=range]{accent-color:var(--acc)}
+.warn{font-size:11px;color:#1a1205;background:var(--new);border-radius:6px;padding:2px 9px;font-weight:700}
 .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:16px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}
+.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
 .imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
 .imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
 .imgwrap.noimg::after{content:'no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#3a4150;font-size:11px;letter-spacing:.5px}
-.card .b{padding:9px 10px;display:flex;flex-direction:column;gap:4px}
+.card .b{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
 .vend{font-size:11px;color:var(--acc);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
 .ttl{font-weight:600;line-height:1.25}
 .sub{color:var(--mut);font-size:12px}
-.when{color:var(--mut);font-size:11px;display:flex;align-items:center;gap:4px;margin-top:2px}
-.row{display:flex;justify-content:space-between;align-items:center;gap:6px}
 .sku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut)}
-a.open{color:var(--acc);text-decoration:none;font-size:11px;border:1px solid var(--line);border-radius:6px;padding:2px 7px}
-a.open:hover{background:var(--panel)}
-#more{display:block;margin:8px auto 40px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
-#empty{padding:60px;text-align:center;color:var(--mut)}
-.card{position:relative}
-.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
 .pick{position:absolute;top:7px;left:7px;z-index:3;background:#000a;border-radius:6px;padding:3px 4px;display:flex;cursor:pointer}
 .pick input{width:17px;height:17px;accent-color:var(--acc);cursor:pointer;margin:0}
+/* ── status chip (always visible) + chip row ── */
+.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
+.chip{font-size:10.5px;font-weight:700;border-radius:999px;padding:2px 9px;letter-spacing:.3px;white-space:nowrap;border:1px solid transparent}
+.chip.st-NEW{background:var(--new);color:#1a1205}
+.chip.st-PUBLISHED{background:var(--pub);color:#04211a}
+.chip.st-UNPUBLISHED{background:var(--unpub);color:#11151c}
+.chip.st-ARCHIVED{background:var(--arch);color:#2a0606}
+.chip.st-DELETED{background:var(--del);color:#0b0d11}
+.chip.st-ON_SHOPIFY{background:var(--ship);color:#04122a}
+.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
+.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
+/* ── collapsed detail drawer ── */
+.details{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);margin-top:2px;padding-top:8px}
+.card.open .details{display:flex}
+.kv{display:flex;justify-content:space-between;gap:8px;font-size:11.5px}
+.kv .k{color:var(--mut)}
+.kv .v{text-align:right;word-break:break-word}
+.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
+.lnk:hover{background:var(--panel)}
+.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
+.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
+.act:hover{border-color:var(--acc)}
+.act.pub{border-color:var(--pub);color:var(--pub)}
+.act.unpub{border-color:var(--unpub);color:var(--unpub)}
+.act.arch{border-color:var(--arch);color:var(--arch)}
+.act:disabled{opacity:.4;cursor:default}
+.actmsg{font-size:11px;color:var(--mut);min-height:14px}
+#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
+#empty{padding:60px;text-align:center;color:var(--mut)}
 .btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
 .btn:hover{border-color:var(--acc)}
 #selbar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:#11131bF2;backdrop-filter:blur(10px);
- border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:14px;flex-wrap:wrap}
+ border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:12px;flex-wrap:wrap}
 #selbar.on{display:flex}
 #selbar .n{font-weight:700;color:var(--acc)}
-#launch{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
-#launch:disabled{opacity:.4;cursor:default}
-/* ── List view ── aligned compact rows via per-card CSS grid (display:contents
-   hoists .b's children into the card grid so columns line up across rows). */
-.grid.list{grid-template-columns:1fr;gap:3px;padding:10px 16px}
-.grid.list .card{display:grid;grid-template-columns:26px 44px 150px 320px 110px 200px minmax(250px,1fr);
-  align-items:center;gap:0 14px;padding:4px 14px;border-radius:8px;min-height:0}
-.grid.list .b{display:contents}
-.grid.list .pick{position:static;background:none;padding:0;justify-self:center}
-.grid.list .imgwrap{width:42px;height:42px;aspect-ratio:auto;border-radius:6px;margin:0}
-.grid.list .imgwrap.noimg::after{font-size:8px}
-.grid.list .vend{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .ttl{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .row{min-width:0;justify-content:flex-start;gap:8px}
-.grid.list .row .sub,.grid.list .row .sku{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.grid.list .when{margin:0;justify-content:flex-start;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}
+#bulkgo{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
+#bulkgo:disabled{opacity:.4;cursor:default}
+/* ── List view ── */
+.grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px}
+.grid.list .card{flex-direction:row;align-items:center;border-radius:8px;flex-wrap:wrap}
+.grid.list .imgwrap{width:52px;min-width:52px;height:52px;aspect-ratio:auto;border-radius:6px;overflow:hidden;margin:6px 0 6px 6px}
+.grid.list .imgwrap.noimg::after{font-size:9px}
+.grid.list .pick{position:static;background:none;padding:0 6px 0 8px;flex:0 0 auto}
+.grid.list .b{flex:1;flex-direction:row;align-items:center;gap:14px;padding:6px 14px;flex-wrap:nowrap;overflow:hidden}
+.grid.list .vend{order:1;flex:0 0 150px;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .ttl{order:2;flex:1 1 200px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .chips{order:3;flex:0 0 auto}
+.grid.list .details{flex:1 1 100%;order:5}
 </style></head>
 <body>
 <header>
-  <h1>Products New to Import</h1>
+  <h1>Catalog · Shopify state</h1>
   <span class="pill" id="total">…</span>
   <span class="muted" id="meta"></span>
+  <div class="ctl"><label class="muted">Status</label>
+    <select id="status">
+      <option value="new">🟠 New to import</option>
+      <option value="onshopify">🔵 On Shopify (all)</option>
+      <option value="published">🟢 Published</option>
+      <option value="unpublished">⚪ Unpublished</option>
+      <option value="archived">🔴 Archived</option>
+      <option value="all">▦ All</option>
+    </select></div>
   <div class="ctl"><label class="muted">Vendor</label>
     <select id="vendor"><option value="">All vendors</option></select></div>
   <div class="ctl"><input type="text" id="q" placeholder="search pattern / sku / color…"></div>
@@ -79,6 +109,7 @@ a.open:hover{background:var(--panel)}
     <input type="range" id="density" min="3" max="10" step="1"></div>
   <button class="btn" id="view">▤ List view</button>
   <button class="btn" id="selall">☑ Select all shown</button>
+  <span class="warn" id="livebadge" style="display:none">LIVE WRITES ON</span>
 </header>
 <div class="grid" id="grid"></div>
 <button id="more" style="display:none">Load more</button>
@@ -86,44 +117,75 @@ a.open:hover{background:var(--panel)}
 <div id="selbar">
   <span class="n" id="seln">0 selected</span>
   <button class="btn" id="clearsel">clear</button>
-  <span class="muted">launch as</span>
-  <select id="mode"><option value="draft">DRAFT (review first)</option><option value="active">ACTIVE (live on create)</option></select>
-  <button id="launch">🚀 Launch to Shopify ▸</button>
-  <span class="muted" id="launchmsg"></span>
+  <span class="muted">bulk action</span>
+  <select id="bulkact">
+    <option value="launch:draft">🚀 Stage launch → DRAFT</option>
+    <option value="launch:active">🚀 Stage launch → ACTIVE</option>
+    <option value="publish:">🟢 Stage publish</option>
+    <option value="unpublish:">⚪ Stage unpublish</option>
+    <option value="archive:">🔴 Stage archive</option>
+  </select>
+  <button id="bulkgo">Apply ▸</button>
+  <span class="muted" id="bulkmsg"></span>
 </div>
 <script>
 const $=s=>document.querySelector(s);
-const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false};
+const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false};
 const sel=new Set();
-function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#launch').disabled=!n;}
+const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
+function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#bulkgo').disabled=!n;}
 function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
+function toggleCard(el){el.closest('.card').classList.toggle('open');}
 // persisted prefs (CLAUDE.md: sort + density persist in localStorage)
+$('#status').value=localStorage.getItem('nip.status')||'new';
 $('#sort').value=localStorage.getItem('nip.sort')||'newest';
 const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
 document.documentElement.style.setProperty('--cols',dens);
 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+const SHOP='https://admin.shopify.com/store/designer-wallcoverings/products/';
+// Which staged actions make sense for a given state.
+function actionsFor(st){
+  if(st==='NEW') return [['launch:draft','Stage → Draft',''],['launch:active','Stage → Active','pub']];
+  if(st==='PUBLISHED') return [['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
+  if(st==='UNPUBLISHED') return [['publish:','Publish','pub'],['archive:','Archive','arch']];
+  if(st==='ARCHIVED') return [['publish:','Publish (restore)','pub']];
+  if(st==='ON_SHOPIFY') return [['publish:','Publish','pub'],['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
+  return [];
+}
 function card(p){
   const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
   const img=p.image?`<div class="imgwrap"><img loading="lazy" src="${esc(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
                     :`<div class="imgwrap noimg"></div>`;
-  const crawled=p.crawled?`🕓 ${esc(p.crawled)}`:'🕓 (no crawl date)';
-  const seen=p.firstSeen?` · first seen ${esc(p.firstSeen)}`:'';
   const checked=sel.has(p.id)?'checked':'';
-  return `<div class="card${checked?' sel':''}" data-id="${p.id}">
+  const st=p.shopStatus||'NEW';
+  const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+  const shopLink=p.handle?`<a class="lnk" href="${SHOP}${esc(p.handle)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`
+                :(p.shopifyId?`<a class="lnk" href="${SHOP}${esc(p.shopifyId)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`:'');
+  const vendLink=p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:'';
+  const acts=actionsFor(st).map(([a,label,cls])=>`<button class="act ${cls}" onclick="rowAction(${p.id},'${a}',this)">${label}</button>`).join('');
+  return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}">
     <label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
     ${img}<div class="b">
-    <span class="vend">${esc(p.vendor)}${p.collection?(' · '+esc(p.collection)):''}</span>
+    <span class="vend">${esc(p.vendor)}</span>
     <div class="ttl">${title}</div>
-    <div class="row"><span class="sub">${esc(p.type||'')}</span>
-      ${p.url?`<a class="open" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:''}</div>
-    <div class="row"><span class="sku">${esc(p.sku||'')}${p.dwSku?(' · '+esc(p.dwSku)):''}</span></div>
-    <div class="when" title="crawl date+time (when this run last saw it on the vendor site)">${crawled}${seen}</div>
+    <div class="chips">
+      <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
+      <span class="chip toggle" onclick="toggleCard(this)">⋯ details &amp; actions</span>
+    </div>
+    <div class="details">
+      ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
+      ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
+      ${kv('First seen',p.firstSeen)}${kv('Last crawl',p.crawled)}${kv('Shopify synced',p.shopSynced)}
+      <div class="chips">${vendLink}${shopLink}</div>
+      <div class="acts">${acts}</div>
+      <div class="actmsg" id="msg-${p.id}"></div>
+    </div>
   </div></div>`;
 }
 async function load(reset){
   if(state.loading) return; state.loading=true;
   if(reset){ state.offset=0; grid.innerHTML=''; }
-  const p=new URLSearchParams({vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value,limit:state.limit,offset:state.offset});
+  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value,limit:state.limit,offset:state.offset});
   const d=await (await fetch('/api/new-products?'+p)).json();
   state.count=d.count; state.offset+=d.items.length;
   grid.insertAdjacentHTML('beforeend', d.items.map(card).join(''));
@@ -132,13 +194,26 @@ async function load(reset){
   $('#more').style.display=state.offset<d.count?'block':'none';
   state.loading=false;
 }
+async function reloadVendors(){
+  const vs=await (await fetch('/api/vendors?status='+$('#status').value)).json();
+  $('#vendor').innerHTML='<option value="">All vendors</option>'+vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join('');
+}
+function setHeaderCount(counts){
+  const cur=$('#status').value;
+  $('#total').textContent=((counts||{})[cur]!=null?counts[cur]:0).toLocaleString()+' '+cur;
+}
 async function boot(){
   const s=await (await fetch('/api/stats')).json();
-  $('#total').textContent=s.total.toLocaleString()+' new';
-  const vs=await (await fetch('/api/vendors')).json();
-  $('#vendor').insertAdjacentHTML('beforeend', vs.map(v=>`<option value="${esc(v.vendor)}">${esc(v.vendor)} (${v.count.toLocaleString()})</option>`).join(''));
-  load(true);
+  state.live=s.live; const c=s.counts||{};
+  setHeaderCount(c);
+  [['new','🟠 New to import'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['all','▦ All']].forEach(([k,lbl])=>{
+    const o=$('#status').querySelector(`option[value="${k}"]`); if(o&&c[k]!=null) o.textContent=`${lbl} (${c[k].toLocaleString()})`;
+  });
+  if(s.live) $('#livebadge').style.display='inline';
+  window.__counts=c;
+  await reloadVendors(); load(true);
 }
+$('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);await reloadVendors();load(true);};
 $('#vendor').onchange=()=>load(true);
 $('#sort').onchange=()=>{localStorage.setItem('nip.sort',$('#sort').value);load(true);};
 let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
@@ -149,18 +224,30 @@ $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('ni
 applyView();
 $('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
 $('#clearsel').onclick=()=>{sel.clear();grid.querySelectorAll('.card.sel').forEach(c=>{c.classList.remove('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=false;});syncSel();};
-$('#launch').onclick=async()=>{
+async function rowAction(id,spec,btn){
+  const [action,mode]=spec.split(':');
+  if(!confirm(`${action.toUpperCase()} 1 product?\n\nThis STAGES the action to the launch queue. ${state.live?'Live writes ON but per-action push not yet wired — still stages only.':'Live Shopify writes are OFF — nothing reaches the store.'}`))return;
+  btn.disabled=true; const msg=$('#msg-'+id); if(msg)msg.textContent='staging…';
+  try{
+    const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode})});
+    const d=await r.json(); if(d.error)throw new Error(d.error);
+    if(msg)msg.textContent=`✅ staged ${action}${mode?(' ('+mode+')'):''} — batch ${d.batchId}`;
+  }catch(e){if(msg)msg.textContent='✗ '+e.message;}
+  btn.disabled=false;
+}
+window.rowAction=rowAction; window.togglePick=togglePick; window.toggleCard=toggleCard;
+$('#bulkgo').onclick=async()=>{
   const ids=[...sel]; if(!ids.length)return;
-  const mode=$('#mode').value;
-  if(!confirm(`Launch ${ids.length} product(s) to Shopify as ${mode.toUpperCase()}?\n\nThis STAGES the batch into the launch queue. The live Shopify push is a separate gated step — nothing goes to the store yet.`))return;
-  $('#launch').disabled=true; $('#launchmsg').textContent='staging…';
+  const [action,mode]=$('#bulkact').value.split(':');
+  if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nStages to the launch queue. ${state.live?'Live writes ON but per-action push not yet wired — stages only.':'Live Shopify writes are OFF — nothing reaches the store.'}`))return;
+  $('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
   try{
-    const r=await fetch('/api/launch',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,mode})});
+    const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,action,mode})});
     const d=await r.json(); if(d.error)throw new Error(d.error);
     const vlist=Object.entries(d.byVendor||{}).map(([v,n])=>`${v}:${n}`).join(', ');
-    $('#launchmsg').textContent=`✅ staged ${d.staged} as ${d.mode} — ${vlist}`;
-  }catch(e){$('#launchmsg').textContent='✗ '+e.message;}
-  $('#launch').disabled=false;
+    $('#bulkmsg').textContent=`✅ staged ${d.staged} ${action} — ${vlist}`;
+  }catch(e){$('#bulkmsg').textContent='✗ '+e.message;}
+  $('#bulkgo').disabled=false;
 };
 addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
 syncSel();
diff --git a/server.js b/server.js
index 9093463..07cd2b8 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,9 @@
 #!/usr/bin/env node
 // new-import-viewer — the "Products New to Import" agent + web viewer.
-// Surfaces every vendor_catalog row the crawl flagged as NOT yet on Shopify
-// (sync_status='new' / on_shopify=false) so Steve can review what the latest
-// vendor crawl surfaced before a Phase-8 push. Read-only against dw_unified.
+// Surfaces vendor_catalog rows by Shopify state (NEW / PUBLISHED / UNPUBLISHED /
+// ARCHIVED) so Steve can review the catalog and stage actions before a Phase-8
+// push. Read-only against dw_unified EXCEPT the staged launch-queue. Live Shopify
+// writes are GATED behind SHOPIFY_LIVE_WRITES=1 (default off → stage-only).
 // Zero npm deps — shells out to psql (same pattern as wallco-live-viewer).
 const http = require('http');
 const fs = require('fs');
@@ -13,36 +14,89 @@ const PORT = process.env.PORT || 9790;
 const PSQL = process.env.PSQL || '/opt/homebrew/opt/postgresql@14/bin/psql';
 const PGUSER = process.env.PGUSER || 'stevestudio2';
 const PGDB = process.env.PGDATABASE || 'dw_unified';
+const LIVE = process.env.SHOPIFY_LIVE_WRITES === '1';   // gate: real store writes
 const US = '\x1f';        // unit separator — safe field delim for text columns
 const RS = '\x1e';        // record separator
 
 // NEW-to-import predicate — the single source of truth for "new vs Shopify".
-const NEW_PRED = "(sync_status='new' OR ((on_shopify IS NOT TRUE) AND shopify_product_id IS NULL))";
+const NEW_PRED = "(vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))";
+// Resolve one shopify_products row per vendor_catalog row. shopify_id is a
+// gid://shopify/Product/<n> string (UNIQUE index); vc.shopify_product_id is the
+// bare bigint. Construct the gid on the vc side so the unique index on
+// shopify_id does the lookup (don't wrap shopify_id in a function — that defeats
+// the index and seq-scans per row). NULL pid → NULL gid → no match, cheap.
+const SP_JOIN = `LEFT JOIN shopify_products sp
+  ON sp.shopify_id = 'gid://shopify/Product/' || vc.shopify_product_id::text`;
+// Derived, normalized Shopify state for a row. GROUND TRUTH wins: if a live
+// shopify_products row matches, show its real status even when the pipeline
+// still flags the row sync_status='new' (that mismatch is itself a useful
+// dedup signal — "flagged new but actually already live as X").
+const SHOP_STATUS = `CASE
+  WHEN upper(sp.status)='ACTIVE' THEN 'PUBLISHED'
+  WHEN upper(sp.status)='DRAFT' THEN 'UNPUBLISHED'
+  WHEN upper(sp.status)='ARCHIVED' THEN 'ARCHIVED'
+  WHEN upper(sp.status) LIKE 'DELETED%' THEN 'DELETED'
+  WHEN vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE THEN 'ON_SHOPIFY'
+  ELSE 'NEW' END`;
+// Status filters the UI can request. Keys map to a WHERE fragment.
+const STATUS_FILTERS = {
+  new:         NEW_PRED,
+  all:         'TRUE',
+  onshopify:   '(vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE)',
+  published:   "upper(sp.status)='ACTIVE'",
+  unpublished: "upper(sp.status)='DRAFT'",
+  archived:    "upper(sp.status)='ARCHIVED'",
+};
 
 function q(sql) {
   const r = spawnSync(PSQL, ['-U', PGUSER, '-d', PGDB, '-tA', '-F', US, '-R', RS, '-c', sql],
-    { encoding: 'utf8', timeout: 30000, maxBuffer: 1 << 28 });
+    { encoding: 'utf8', timeout: 60000, maxBuffer: 1 << 28 });
   if (r.status !== 0) throw new Error((r.stderr || 'psql failed').slice(0, 400));
-  // psql appends a trailing newline to the whole output — strip it before splitting
-  // so the final field of the final row doesn't carry a stray \n.
   return (r.stdout || '').replace(/\n+$/, '').split(RS).filter(l => l.length).map(l => l.split(US));
 }
 const esc = (s) => String(s == null ? '' : s).replace(/'/g, "''");
 const SORTS = {
-  newest:    'last_scraped_at DESC NULLS LAST, id DESC',
-  oldest:    'last_scraped_at ASC NULLS LAST, id ASC',
-  firstseen: 'first_seen_at DESC NULLS LAST, id DESC',
-  vendor:    "vendor_code ASC, pattern_name ASC",
-  sku:       "mfr_sku ASC",
-  pattern:   "pattern_name ASC, color_name ASC",
-  color:     "color_name ASC, pattern_name ASC",
+  newest:    'vc.last_scraped_at DESC NULLS LAST, vc.id DESC',
+  oldest:    'vc.last_scraped_at ASC NULLS LAST, vc.id ASC',
+  firstseen: 'vc.first_seen_at DESC NULLS LAST, vc.id DESC',
+  vendor:    "vc.vendor_code ASC, vc.pattern_name ASC",
+  sku:       "vc.mfr_sku ASC",
+  pattern:   "vc.pattern_name ASC, vc.color_name ASC",
+  color:     "vc.color_name ASC, vc.pattern_name ASC",
 };
 
+// Build the WHERE clause shared by listing + counts.
+function buildWhere({ status, vendor, term }) {
+  const parts = [STATUS_FILTERS[status] || NEW_PRED];
+  if (vendor) parts.push(`vc.vendor_code='${esc(vendor)}'`);
+  if (term) {
+    const t = `%${esc(term)}%`;
+    parts.push(`(vc.pattern_name ILIKE '${t}' OR vc.mfr_sku ILIKE '${t}' OR vc.color_name ILIKE '${t}' OR vc.dw_sku ILIKE '${t}' OR vc.collection ILIKE '${t}')`);
+  }
+  return 'WHERE ' + parts.join(' AND ');
+}
+
 function send(res, code, body, type) {
   res.writeHead(code, { 'content-type': type || 'application/json', 'cache-control': 'no-store' });
   res.end(body);
 }
 
+// Stage an action to the append-only launch queue. NEVER writes to the store.
+function stageAction({ ids, action, mode }) {
+  const idList = ids.join(',');
+  const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, ${SHOP_STATUS} AS st
+                  FROM vendor_catalog vc ${SP_JOIN}
+                  WHERE vc.id IN (${idList})`);
+  const batchId = action + '-' + rows.length + '-' + (ids[0] || 0) + '-' + Date.now().toString(36);
+  const skus = rows.map(r => ({ id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4], from: r[5] }));
+  const byVendor = {};
+  rows.forEach(r => (byVendor[r[1]] = (byVendor[r[1]] || 0) + 1));
+  const qf = path.join(__dirname, 'data', 'launch-queue.jsonl');
+  fs.mkdirSync(path.dirname(qf), { recursive: true });
+  fs.appendFileSync(qf, JSON.stringify({ batchId, action, mode, count: rows.length, skus: rows.length, at: new Date().toISOString() }) + '\n');
+  return { ok: true, staged: rows.length, action, mode, batchId, byVendor, live: false };
+}
+
 const server = http.createServer((req, res) => {
   try {
     const u = new URL(req.url, 'http://x');
@@ -50,68 +104,81 @@ const server = http.createServer((req, res) => {
       return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
     }
     if (u.pathname === '/api/stats') {
-      const r = q(`SELECT count(*), count(*) FILTER (WHERE image_url<>''), count(DISTINCT vendor_code),
-                          to_char(max(last_scraped_at),'YYYY-MM-DD HH24:MI')
-                   FROM vendor_catalog WHERE ${NEW_PRED}`)[0] || [];
-      return send(res, 200, JSON.stringify({ total: +r[0] || 0, withImage: +r[1] || 0, vendors: +r[2] || 0, newestCrawl: r[3] || null }));
+      // Whole-catalog status breakdown for the header pills (vendor/term ignored here).
+      const r = q(`SELECT
+          count(*) FILTER (WHERE ${NEW_PRED}),
+          count(*) FILTER (WHERE vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE),
+          count(*) FILTER (WHERE upper(sp.status)='ACTIVE'),
+          count(*) FILTER (WHERE upper(sp.status)='DRAFT'),
+          count(*) FILTER (WHERE upper(sp.status)='ARCHIVED'),
+          count(*),
+          to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
+        FROM vendor_catalog vc ${SP_JOIN}`)[0] || [];
+      return send(res, 200, JSON.stringify({
+        live: LIVE,
+        counts: { new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
+                  unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0 },
+        newestCrawl: r[6] || null,
+      }));
     }
     if (u.pathname === '/api/vendors') {
-      const rows = q(`SELECT vendor_code, count(*),
-                             to_char(max(last_scraped_at),'YYYY-MM-DD HH24:MI')
-                      FROM vendor_catalog WHERE ${NEW_PRED}
-                      GROUP BY vendor_code ORDER BY count(*) DESC`);
+      const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
+      const where = buildWhere({ status, vendor: '', term: '' });
+      const rows = q(`SELECT vc.vendor_code, count(*),
+                             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] }))));
     }
     if (u.pathname === '/api/new-products') {
+      const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
       const vendor = (u.searchParams.get('vendor') || '').replace(/[^a-z0-9_]/gi, '').slice(0, 64);
       const sort = SORTS[u.searchParams.get('sort')] ? u.searchParams.get('sort') : 'newest';
       const term = (u.searchParams.get('q') || '').slice(0, 80);
       const limit = Math.min(parseInt(u.searchParams.get('limit'), 10) || 120, 500);
       const offset = Math.max(parseInt(u.searchParams.get('offset'), 10) || 0, 0);
-      let where = `WHERE ${NEW_PRED}`;
-      if (vendor) where += ` AND vendor_code='${esc(vendor)}'`;
-      if (term) {
-        const t = `%${esc(term)}%`;
-        where += ` AND (pattern_name ILIKE '${t}' OR mfr_sku ILIKE '${t}' OR color_name ILIKE '${t}' OR dw_sku ILIKE '${t}' OR collection ILIKE '${t}')`;
-      }
-      const rows = q(`SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name, color_name, collection,
-                             product_type, image_url, product_url,
-                             to_char(first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
-                             to_char(last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
-                             sync_status
-                      FROM vendor_catalog ${where}
+      const where = buildWhere({ status, vendor, term });
+      const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, vc.color_name, vc.collection,
+                             vc.product_type, vc.image_url, vc.product_url,
+                             to_char(vc.first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
+                             to_char(vc.last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
+                             vc.sync_status, ${SHOP_STATUS} AS shop_status,
+                             vc.shopify_product_id, sp.handle,
+                             to_char(vc.shopify_synced_at,'YYYY-MM-DD HH24:MI') AS shop_synced
+                      FROM vendor_catalog vc ${SP_JOIN} ${where}
                       ORDER BY ${SORTS[sort]} LIMIT ${limit} OFFSET ${offset}`);
-      const cnt = +(q(`SELECT count(*) FROM vendor_catalog ${where}`)[0] || [0])[0];
+      const cnt = +(q(`SELECT count(*) FROM vendor_catalog vc ${SP_JOIN} ${where}`)[0] || [0])[0];
       const items = rows.map(r => ({
         id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4], color: r[5],
         collection: r[6], type: r[7], image: r[8], url: r[9],
-        firstSeen: r[10], crawled: r[11], status: r[12],
+        firstSeen: r[10], crawled: r[11], syncStatus: r[12], shopStatus: r[13],
+        shopifyId: r[14] || null, handle: r[15] || null, shopSynced: r[16] || null,
       }));
       return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
     }
-    if (u.pathname === '/api/launch' && req.method === 'POST') {
+    // Generalized action endpoint — stages launch / publish / unpublish / archive.
+    // /api/launch kept as a back-compat alias (action='launch').
+    if ((u.pathname === '/api/action' || u.pathname === '/api/launch') && req.method === 'POST') {
       let body = '';
       req.on('data', c => (body += c));
       req.on('end', () => {
         try {
           const d = JSON.parse(body || '{}');
           const ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
-          const mode = d.mode === 'active' ? 'active' : 'draft';
+          const ACTIONS = ['launch', 'publish', 'unpublish', 'archive'];
+          const action = u.pathname === '/api/launch' ? 'launch'
+                       : (ACTIONS.includes(d.action) ? d.action : null);
+          if (!action) return send(res, 400, JSON.stringify({ error: 'unknown action' }));
+          const mode = d.mode === 'active' ? 'active' : 'draft';   // only meaningful for launch
           if (!ids.length) return send(res, 400, JSON.stringify({ error: 'no products selected' }));
-          // Resolve the selected rows so the manifest carries vendor + sku, not bare ids.
-          const rows = q(`SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name
-                          FROM vendor_catalog WHERE id IN (${ids.join(',')}) AND ${NEW_PRED}`);
-          const batchId = 'launch-' + rows.length + '-' + ids[0];
-          const entry = { batchId, mode, count: rows.length,
-            skus: rows.map(r => ({ id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4] })) };
-          // STAGE only — append to a launch queue. The live Shopify push (Phase 8)
-          // is a separate gated step; this never writes to the store.
-          const qf = path.join(__dirname, 'data', 'launch-queue.jsonl');
-          fs.mkdirSync(path.dirname(qf), { recursive: true });
-          fs.appendFileSync(qf, JSON.stringify({ ...entry, skus: entry.skus.length }) + '\n');
-          const byVendor = {};
-          rows.forEach(r => (byVendor[r[1]] = (byVendor[r[1]] || 0) + 1));
-          return send(res, 200, JSON.stringify({ ok: true, staged: rows.length, mode, batchId, byVendor }));
+          const out = stageAction({ ids, action, mode });
+          // LIVE writes are scaffolded but gated. When SHOPIFY_LIVE_WRITES=1 this is
+          // where the Shopify Admin API call would fire per action; left disabled
+          // until Steve OKs each action type.
+          out.note = LIVE
+            ? 'staged — live writes ENABLED but per-action push not yet wired (awaiting sign-off)'
+            : 'staged only — live Shopify writes are OFF (SHOPIFY_LIVE_WRITES!=1)';
+          return send(res, 200, JSON.stringify(out));
         } catch (e) { return send(res, 500, JSON.stringify({ error: e.message })); }
       });
       return;
@@ -121,4 +188,4 @@ const server = http.createServer((req, res) => {
     return send(res, 500, JSON.stringify({ error: e.message }));
   }
 });
-server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT}  (NEW-to-import from dw_unified.vendor_catalog)`));
+server.listen(PORT, () => console.log(`[new-import-viewer] http://127.0.0.1:${PORT}  (Shopify-state catalog · live-writes=${LIVE ? 'ON' : 'OFF'})`));

← af2b077 snapshot before shopify-status + collapsed-chips rebuild  ·  back to New Import Viewer  ·  Wire live Shopify unpublish behind double gate (master switc b2cb29b →