← back to Gmc Titlefix

review-viewer.mjs

176 lines

#!/usr/bin/env node
// GMC $4.25 title-fix — review viewer (zero-dep). http://localhost:9934
// Reads the 29,672 before/after list + the gated memo + live vanguard status.
import http from 'http';
import fs from 'fs';
const PORT = 9934;
const DIR = '/Users/macstudio3/Projects/gmc-titlefix';
const MEMO = '/Users/macstudio3/.claude/yolo-queue/pending-approval/30-gmc-title-fix.md';
const LIST = `${DIR}/gmc_titlefix_list.json`;
const DS_FILE = '/tmp/gmc_titlefix_ds.txt';
const STEPB = '/tmp/gmc_stepB.out';
const VERIFY = '/tmp/gmc-titlefix-verify-pass.txt';

let ROWS = [];
try { ROWS = JSON.parse(fs.readFileSync(LIST, 'utf8')); } catch {}
const SHOP = 'designer-laboratory-sandbox';
const SHOP_DOMAIN = `${SHOP}.myshopify.com`;
let TOKEN = '';
try { TOKEN = (fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m)||[])[1]?.replace(/['"]/g,'').trim() || ''; } catch {}
const handleCache = {};
async function resolveHandles(ids){
  const need = ids.filter(id => handleCache[id] === undefined);
  for (let i=0;i<need.length;i+=120){
    const batch = need.slice(i,i+120).map(x=>`gid://shopify/Product/${x}`);
    try{
      const r = await fetch(`https://${SHOP_DOMAIN}/admin/api/2024-10/graphql.json`,{method:'POST',
        headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
        body:JSON.stringify({query:`query($ids:[ID!]!){nodes(ids:$ids){... on Product{id handle status onlineStoreUrl}}}`,variables:{ids:batch}})});
      const j = await r.json();
      for (const n of (j.data?.nodes||[])){ if(n){ const pid=n.id.split('/').pop(); handleCache[pid]={handle:n.handle,status:n.status,live:!!n.onlineStoreUrl}; } }
      for (const id of need.slice(i,i+120)) if(handleCache[id]===undefined) handleCache[id]=null; // mark missing
    }catch(e){ for (const id of need.slice(i,i+120)) handleCache[id]=null; }
  }
  const out={}; for(const id of ids) out[id]=handleCache[id]||null; return out;
}
const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };

// vendor tally (from "by <Vendor>" / "| <Vendor>" suffix) — best-effort
function vendorOf(t){ const m=(t||'').match(/(?:by|\|)\s+([^|]+?)\s*$/i); return m?m[1].trim().slice(0,28):'(other)'; }

const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>GMC $4.25 Title-Fix — Review</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
 :root{--bg:#0b0e14;--panel:#11151f;--ink:#e7ecf3;--dim:#8a94a6;--line:#1f2735;--ok:#34d399;--warn:#fbbf24;--bad:#f87171;--accent:#34f4ce;--add:#7dd3fc}
 *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:13.5px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Arial}
 header{padding:16px 24px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#0b0e14;z-index:5}
 h1{margin:0;font-size:18px}h1 small{color:var(--dim);font-weight:400;font-size:12px;margin-left:8px}
 main{max-width:1280px;margin:0 auto;padding:18px 24px 80px}
 .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:4px 0 16px}
 .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:11px 13px}
 .card .k{font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--dim)}
 .card .v{font-size:23px;font-weight:700;margin-top:2px}
 .steps{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 18px}
 .step{flex:1;min-width:150px;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:9px 12px;font-size:12px}
 .step b{display:block;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
 .pill{font-size:11px;padding:1px 8px;border-radius:10px;border:1px solid var(--line)}
 .pend{color:var(--dim)} .go{color:var(--ok);border-color:#1c5} .hold{color:var(--warn);border-color:#a83}
 h2{font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--dim);margin:22px 0 8px}
 .memo{background:#0a0e16;border:1px solid var(--line);border-radius:9px;padding:12px 14px;white-space:pre-wrap;font:12px/1.5 ui-monospace,monospace;color:#c8d2e0;max-height:340px;overflow:auto}
 .toolbar{display:flex;gap:10px;align-items:center;margin:6px 0 10px;flex-wrap:wrap}
 #q{flex:1;max-width:420px;background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;border-radius:8px;padding:8px 11px}
 #q:focus{outline:none;border-color:var(--accent)}
 select{background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;border-radius:8px;padding:7px 9px}
 table{width:100%;border-collapse:collapse;font-size:12.5px}
 th,td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);vertical-align:top}
 th{color:var(--dim);font-size:10.5px;text-transform:uppercase;letter-spacing:.04em}
 .cur{color:var(--bad)} .prop{color:var(--ok)} .prop b{color:var(--accent)}
 td.n{font:11px ui-monospace,monospace;color:var(--dim);white-space:nowrap}
 .pager{display:flex;gap:8px;align-items:center;margin:12px 0;color:var(--dim)}
 .pager button{background:#121826;border:1px solid var(--line);color:#e7ecf3;border-radius:7px;padding:5px 12px;cursor:pointer}
 .pager button:disabled{opacity:.4;cursor:default}
 .trunc{color:var(--warn);font-size:10px;border:1px solid #a83;border-radius:8px;padding:0 5px;margin-left:5px}
 .note{color:var(--dim);font-size:11.5px}
</style></head><body>
<header><h1>GMC $4.25 Title-Fix <small>Review — 29,672 sample-only offers · "Sample: " prefix · Google-feed only</small></h1></header>
<main>
 <div class="cards" id="cards"></div>
 <h2>Vanguard (gated) — test before scale</h2>
 <div class="steps" id="steps"></div>
 <h2>Before → After (the exact change to each Google feed title)</h2>
 <div class="toolbar">
   <input id="q" placeholder="🔎 filter by title / SKU / productId…">
   <select id="vendor"><option value="">All vendors</option></select>
   <label class="note"><input type="checkbox" id="tonly"> truncated only</label>
   <span class="note" id="count"></span>
 </div>
 <table><thead><tr><th style="width:42px">#</th><th>Current feed title ($4.25)</th><th>→ Proposed</th><th style="width:120px">links</th></tr></thead><tbody id="rows"></tbody></table>
 <div class="pager"><button id="prev">◀ Prev</button><span id="pg"></span><button id="next">Next ▶</button></div>
 <h2>The plan + DTD verdicts + officer sign-offs (gated memo)</h2>
 <div class="memo" id="memo">loading…</div>
</main>
<script>
let page=0, size=50, q='', vendor='', tonly=false, total=0;
async function summary(){
 const s=await (await fetch('/api/summary')).json();
 const c=[['Feed offers (total)',s.feedTotal],['$4.25 offers',s.p425],['Already "Sample"',s.alreadySample],['FIX SET (this review)',s.fixCount],['Truncated (body trimmed)',s.truncated],['Top vendor',s.topVendor]];
 document.getElementById('cards').innerHTML=c.map(([k,v])=>'<div class="card"><div class="k">'+k+'</div><div class="v">'+(typeof v==='number'?v.toLocaleString():v)+'</div></div>').join('');
 const stp=[['A · create datasource',s.stepA],['B · 1-offer canary',s.stepB],['C · verify title flip',s.stepC],['D · scale 29,672',s.stepD]];
 document.getElementById('steps').innerHTML=stp.map(([k,st])=>'<div class="step"><b>'+k+'</b><span class="pill '+(st.cls)+'">'+st.txt+'</span></div>').join('');
 const vs=document.getElementById('vendor');
 if(vs.options.length<2 && s.vendors){ for(const [v,n] of s.vendors) vs.innerHTML+='<option value="'+v+'">'+v+' ('+n+')</option>'; }
}
async function load(){
 const r=await (await fetch('/api/rows?page='+page+'&size='+size+'&q='+encodeURIComponent(q)+'&vendor='+encodeURIComponent(vendor)+'&tonly='+(tonly?1:0))).json();
 total=r.total;
 const ADMIN='https://admin.shopify.com/store/designer-laboratory-sandbox/products/';
 document.getElementById('rows').innerHTML=r.rows.map((x,i)=>'<tr data-pid="'+x.productId+'"><td class="n">'+(page*size+i+1)+'</td><td class="cur">'+esc(x.currentTitle)+'</td><td class="prop">'+hl(x.proposedTitle)+(x.truncated?'<span class=trunc>trim</span>':'')+'</td><td class="n"><a target="_blank" href="'+ADMIN+x.productId+'">⚙️ admin</a><span class="front" data-pid="'+x.productId+'"> · <span class=note>front…</span></span></td></tr>').join('') || '<tr><td colspan=4 class=note>no matches</td></tr>';
 // lazy-resolve storefront handles for this page
 const ids=r.rows.map(x=>x.productId);
 if(ids.length){ fetch('/api/handles?ids='+ids.join(',')).then(r=>r.json()).then(map=>{
   document.querySelectorAll('span.front').forEach(el=>{ const h=map[el.dataset.pid];
     el.innerHTML = h&&h.handle ? ' · <a target="_blank" href="https://designerwallcoverings.com/products/'+h.handle+'">🛍 front</a>'+(h.live?'':' <span class=trunc>off</span>') : ' · <span class=note>(no handle)</span>'; });
 }).catch(()=>{}); }
 document.getElementById('count').textContent=total.toLocaleString()+' rows';
 document.getElementById('pg').textContent='page '+(page+1)+' / '+Math.max(1,Math.ceil(total/size));
 document.getElementById('prev').disabled=page<=0;
 document.getElementById('next').disabled=(page+1)*size>=total;
}
function esc(s){return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
function hl(s){s=esc(s); return s.replace(/^Sample:/,'<b>Sample:</b>');}
async function memo(){ document.getElementById('memo').textContent=await (await fetch('/api/memo')).text(); }
document.getElementById('q').oninput=e=>{q=e.target.value;page=0;load();};
document.getElementById('vendor').onchange=e=>{vendor=e.target.value;page=0;load();};
document.getElementById('tonly').onchange=e=>{tonly=e.target.checked;page=0;load();};
document.getElementById('prev').onclick=()=>{if(page>0){page--;load();}};
document.getElementById('next').onclick=()=>{if((page+1)*size<total){page++;load();}};
summary(); load(); memo(); setInterval(summary,5000);
</script></body></html>`;

function stepStatus(){
  const dsLive = exists(DS_FILE) && read(DS_FILE).includes('dataSources/');
  const b = read(STEPB);
  const bOk = /productInputs|"name"|inserted|200|201/i.test(b) && !/error/i.test(b);
  const cOk = exists(VERIFY);
  return {
    stepA: dsLive ? {cls:'go', txt:'created ✓'} : {cls:'pend', txt:'pending (API enabling)'},
    stepB: dsLive ? (bOk ? {cls:'go', txt:'canary pushed ✓'} : (b?{cls:'hold',txt:'pushed — check log'}:{cls:'pend',txt:'pending'})) : {cls:'pend', txt:'waiting on A'},
    stepC: cOk ? {cls:'go', txt:'VERIFIED — title flipped ✓'} : {cls:'pend', txt:'pending'},
    stepD: {cls:'hold', txt:'HELD for Steve confirm'},
  };
}

http.createServer((req,res)=>{
  const u = new URL(req.url, 'http://x');
  if (u.pathname==='/') { res.writeHead(200,{'content-type':'text/html'}); return res.end(PAGE); }
  if (u.pathname==='/api/summary') {
    const vt={}; for(const r of ROWS){ const v=vendorOf(r.currentTitle); vt[v]=(vt[v]||0)+1; }
    const vendors=Object.entries(vt).sort((a,b)=>b[1]-a[1]).slice(0,40);
    res.writeHead(200,{'content-type':'application/json'});
    return res.end(JSON.stringify({
      feedTotal:105730, p425:62173, alreadySample:26618, fixCount:ROWS.length,
      truncated:ROWS.filter(r=>r.truncated).length, topVendor:(vendors[0]||['—'])[0],
      vendors, ...stepStatus()
    }));
  }
  if (u.pathname==='/api/rows') {
    const page=+(u.searchParams.get('page')||0), size=Math.min(200,+(u.searchParams.get('size')||50));
    const q=(u.searchParams.get('q')||'').toLowerCase(), vendor=u.searchParams.get('vendor')||'', tonly=u.searchParams.get('tonly')==='1';
    let f=ROWS;
    if(vendor) f=f.filter(r=>vendorOf(r.currentTitle)===vendor);
    if(tonly) f=f.filter(r=>r.truncated);
    if(q) f=f.filter(r=>((r.currentTitle||'')+' '+(r.productId||'')+' '+(r.offerId||'')).toLowerCase().includes(q));
    res.writeHead(200,{'content-type':'application/json'});
    return res.end(JSON.stringify({total:f.length, rows:f.slice(page*size,page*size+size)}));
  }
  if (u.pathname==='/api/memo') { res.writeHead(200,{'content-type':'text/plain'}); return res.end(read(MEMO)||'(memo not found)'); }
  if (u.pathname==='/api/handles') {
    const ids=(u.searchParams.get('ids')||'').split(',').filter(Boolean).slice(0,200);
    resolveHandles(ids).then(map=>{ res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(map)); })
      .catch(e=>{ res.writeHead(200,{'content-type':'application/json'}); res.end('{}'); });
    return;
  }
  res.writeHead(404); res.end('nope');
}).listen(PORT, ()=>console.log(`GMC title-fix review viewer → http://localhost:${PORT}  (${ROWS.length} rows)`));