[object Object]

← back to Dw Photo Capture

auto-save: 2026-06-24T17:58:43 (3 files) — data/build.json public/index.html test-allshopify.js

06a420ef86b61df6a448f9d2abd0eebff5df1261 · 2026-06-24 17:58:45 -0700 · Steve Abrams

Files touched

Diff

commit 06a420ef86b61df6a448f9d2abd0eebff5df1261
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 17:58:45 2026 -0700

    auto-save: 2026-06-24T17:58:43 (3 files) — data/build.json public/index.html test-allshopify.js
---
 data/build.json    |  5 +--
 public/index.html  | 20 ++++++++++--
 test-allshopify.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 117 insertions(+), 4 deletions(-)

diff --git a/data/build.json b/data/build.json
index 45cda72..e449a64 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 14,
+  "next": 15,
   "map": {
     "578af86f": 2,
     "bc95fdb0": 3,
@@ -12,6 +12,7 @@
     "423881b4": 10,
     "76a90668": 11,
     "5ef2ebe2": 12,
-    "788487db": 13
+    "788487db": 13,
+    "3a1be30f": 14
   }
 }
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 073c241..0c61c6b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -107,6 +107,7 @@
       <div class="chip on" data-f="need">Needs photo</div>
       <div class="chip" data-f="live">🟢 Live</div>
       <div class="chip" data-f="done">Done</div>
+      <div class="chip" data-f="new" id="chipNew">✨ New</div>
       <div class="chip" data-f="all">All</div>
       <div class="chip" data-f="any">🔎 Any SKU</div>
       <div class="chip" data-f="shop">🌐 All Shopify</div>
@@ -181,6 +182,15 @@ async function doLookup(){
     LOOKUP_INDEXED=d.indexed||0; render();
   }catch(e){ toast('Lookup failed'); }
 }
+// The not-yet-in-Shopify sheet items — photograph one to create + go live.
+let NEWITEMS=[];
+async function loadNew(){
+  try{ const r=await fetch('/api/new'); const d=await r.json();
+    NEWITEMS=(d.items||[]).map(it=>({...it, keep_images:false}));
+    const ch=$('#chipNew'); if(ch) ch.textContent='✨ New ('+(d.total||0)+')';
+    if(filter==='new') render();
+  }catch(e){}
+}
 // Live store-wide search — covers EVERY Shopify SKU, not just Fentucci.
 let SHOPSEARCH=[];
 async function doShopSearch(){
@@ -193,7 +203,7 @@ async function doShopSearch(){
 }
 function render(){
   const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
-  const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : ITEMS;
+  const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS : ITEMS;
   const liveSearch = filter==='any'||filter==='shop'; // server already filtered these
   let list=src.filter(x=>{
     if(filter==='need'&&(x.done||x.skipped))return false;
@@ -212,6 +222,10 @@ function render(){
       : (filter==='shop' ? 'Type any SKU, name, or vendor to search the whole store'
                          : 'Type a SKU, model #, or name to find any product');
     $('#remain').textContent = filter==='shop' ? '🌐 all Shopify' : (LOOKUP_INDEXED?`${LOOKUP_INDEXED} indexed`:'');
+  } else if(filter==='new'){
+    $('#prog').style.width='0%';
+    $('#count').textContent = `${list.length} new SKU${list.length===1?'':'s'} to create`;
+    $('#remain').textContent = '📷 shoot to create + go live';
   } else {
     const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
     $('#prog').style.width=(total?done/total*100:0)+'%';
@@ -221,6 +235,7 @@ function render(){
   const typedQ=($('#q').value||'').trim();
   const emptyMsg = filter==='shop' ? (typedQ?'No Shopify product matches that — try the SKU, pattern name, or vendor.':'🌐 Search EVERY Shopify SKU above — add or update a photo from your phone.')
     : filter==='any' ? (typedQ?'No Fentucci product matches that — try the model # or DW SKU.':'🔎 Search any TWIL/Fentucci SKU above to add or replace its photo.')
+    : filter==='new' ? (typedQ?'No new SKU matches that.':'✅ All new SKUs created — none left to add.')
     : 'Nothing here — switch filter or clear search.';
   grid.innerHTML = list.length? '' : `<div class="empty">${emptyMsg}</div>`;
   for(const x of list){
@@ -510,9 +525,10 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
   document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));c.classList.add('on');filter=c.dataset.f;setLS('filter',filter);
   if(filter==='any'){ $('#q').placeholder='🔎 Any TWIL SKU, model #, or name…'; $('#q').focus(); doLookup(); }
   else if(filter==='shop'){ $('#q').placeholder='🌐 Any Shopify SKU, name, or vendor…'; $('#q').focus(); doShopSearch(); }
+  else if(filter==='new'){ $('#q').placeholder='🔎 Filter the new SKUs…'; loadNew(); }
   else { $('#q').placeholder='🔎 Search name or model #…'; render(); }
 }));
-load(); setInterval(()=>{ if(filter!=='any'&&filter!=='shop') load(); }, 60000);
+load(); loadNew(); setInterval(()=>{ if(filter!=='any'&&filter!=='shop') load(); }, 60000);
 </script>
 </body>
 </html>
diff --git a/test-allshopify.js b/test-allshopify.js
new file mode 100644
index 0000000..0eeb0d1
--- /dev/null
+++ b/test-allshopify.js
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+/**
+ * End-to-end test for the "All Shopify" search + keep/replace photo paths.
+ * Self-contained: creates a throwaway DRAFT product (never published — no price
+ * means the activation gate can't pass it), exercises the real server endpoints,
+ * verifies image counts, then DELETES the test product. No real catalog product
+ * is ever touched. Run: node test-allshopify.js
+ */
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+
+const BASE = 'http://127.0.0.1:9890';
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+let TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
+if (!TOKEN) for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n'))
+  if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) { TOKEN = l.split('=').slice(1).join('=').trim(); break; }
+
+// 1x1 JPEG — content is irrelevant; we only assert image COUNTS change.
+const JPG = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=';
+const DATAURL = 'data:image/jpeg;base64,' + JPG;
+
+function shopify(method, p, payload) {
+  return new Promise((resolve) => {
+    const data = payload ? JSON.stringify(payload) : null;
+    const req = https.request({ host: SHOP, path: `/admin/api/${API}${p}`, method,
+      headers: Object.assign({ 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        data ? { 'Content-Length': Buffer.byteLength(data) } : {}) }, (res) => {
+      let d = ''; res.on('data', c => d += c); res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b }); });
+    });
+    req.on('error', e => resolve({ status: 0, err: e.message }));
+    if (data) req.write(data); req.end();
+  });
+}
+function srv(method, p, payload) {
+  return new Promise((resolve) => {
+    const http = require('http'); const data = payload ? JSON.stringify(payload) : null;
+    const req = http.request(BASE + p, { method, headers: Object.assign({ Authorization: AUTH, 'Content-Type': 'application/json' }, data ? { 'Content-Length': Buffer.byteLength(data) } : {}) }, (res) => {
+      let d = ''; res.on('data', c => d += c); res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b }); });
+    });
+    req.on('error', e => resolve({ status: 0, err: e.message }));
+    if (data) req.write(data); req.end();
+  });
+}
+const imgCount = async (pid) => ((await shopify('GET', `/products/${pid}/images.json`)).body.images || []).length;
+let pass = 0, fail = 0;
+const ok  = (c, m) => { (c ? pass++ : fail++); console.log(`  ${c ? '✓' : '✗ FAIL'} ${m}`); };
+
+(async () => {
+  const TAG = 'ZZTEST-PHOTOCAP-' + process.pid;
+  console.log(`\n── Setup: throwaway DRAFT product (${TAG}) ──`);
+  const created = await shopify('POST', '/products.json', { product: {
+    title: 'ZZ TEST PhotoCapture DELETE ME', vendor: 'ZZTESTVENDOR', status: 'draft', published: false,
+    variants: [{ sku: TAG, price: '0.00' }],          // price 0 → activation gate cannot pass → never goes live
+    images: [{ attachment: JPG }, { attachment: JPG }] // two pre-existing "professional" images
+  } });
+  const pid = created.body && created.body.product && created.body.product.id;
+  if (!pid) { console.log('  could not create test product:', JSON.stringify(created.body)); process.exit(1); }
+  ok(await imgCount(pid) === 2, `seeded with 2 existing images`);
+
+  try {
+    console.log(`\n── Test 1: live store-wide search finds it ──`);
+    const s = await srv('GET', '/api/shopify-search?q=' + encodeURIComponent(TAG));
+    const hit = (s.body.items || []).find(x => x.dw_sku === TAG);
+    ok(!!hit, `/api/shopify-search returns the product by SKU`);
+    ok(hit && hit.keep_images === true, `result is stamped keep_images:true (non-destructive default)`);
+    ok(hit && hit.scope === 'shopify', `result scope = "shopify"`);
+
+    console.log(`\n── Test 2: KEEP path (default) preserves existing imagery ──`);
+    const k = await srv('POST', '/api/photo', { dw_sku: TAG, product_id: pid, dataUrl: DATAURL, keep_images: true });
+    ok(k.body && k.body.ok, `/api/photo (keep) returned ok`);
+    ok(await imgCount(pid) === 3, `image count 2 → 3 (new photo ADDED, originals kept)`);
+    ok(k.body && k.body.live === false, `draft did NOT auto-activate (no price) — gate held`);
+
+    console.log(`\n── Test 3: REPLACE path (armed toggle) wipes to one ──`);
+    const r = await srv('POST', '/api/photo', { dw_sku: TAG, product_id: pid, dataUrl: DATAURL, keep_images: false });
+    ok(r.body && r.body.ok, `/api/photo (replace) returned ok`);
+    ok(await imgCount(pid) === 1, `image count 3 → 1 (replace-all wiped siblings)`);
+  } finally {
+    console.log(`\n── Teardown: delete the throwaway product + local residue ──`);
+    const del = await shopify('DELETE', `/products/${pid}.json`);
+    ok(del.status >= 200 && del.status < 300, `test product deleted (HTTP ${del.status})`);
+    // scrub local progress.json + photo files written for the test sku
+    try {
+      const PF = path.join(__dirname, 'data/progress.json'); const pr = JSON.parse(fs.readFileSync(PF, 'utf8'));
+      if (pr[TAG]) { delete pr[TAG]; fs.writeFileSync(PF, JSON.stringify(pr, null, 2)); }
+      for (const f of fs.readdirSync(path.join(__dirname, 'photos'))) if (f.startsWith(TAG)) fs.unlinkSync(path.join(__dirname, 'photos', f));
+      ok(true, `local progress + photo files scrubbed`);
+    } catch (e) { ok(false, `cleanup error: ${e.message}`); }
+  }
+
+  console.log(`\n══ ${pass} passed, ${fail} failed ══\n`);
+  process.exit(fail ? 1 : 0);
+})();

← 708fac7 All-Shopify cards: per-card 'Replace all — make this the onl  ·  back to Dw Photo Capture  ·  Add ✨ New (N) filter chip (just the net-new sheet SKUs to cr 7e0a77d →