[object Object]

← back to Dw Photo Capture

auto-save: 2026-06-24T15:27:54 (2 files) — public/index.html server.js

ea8d5f1bf28c807c9ffac98ce434147e88e54bff · 2026-06-24 15:27:56 -0700 · Steve Abrams

Files touched

Diff

commit ea8d5f1bf28c807c9ffac98ce434147e88e54bff
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 15:27:56 2026 -0700

    auto-save: 2026-06-24T15:27:54 (2 files) — public/index.html server.js
---
 public/index.html | 13 ++++++----
 server.js         | 76 ++++++++++++++++++++++++++++++++++++++++++++-----------
 2 files changed, 69 insertions(+), 20 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4195e12..c55dc4b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -31,6 +31,7 @@
   .thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
   .badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
   .b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
+  .b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
   .body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:6px;flex:1}
   .ttl{font:600 14px/1.25 "SF Pro Display",sans-serif}
   .sku{font:700 15px/1 ui-monospace,Menlo,monospace;color:var(--gold);letter-spacing:.02em}
@@ -64,6 +65,7 @@
   <div class="controls">
     <div class="chips">
       <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="all">All</div>
     </div>
@@ -94,6 +96,7 @@ function render(){
   const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
   let list=ITEMS.filter(x=>{
     if(filter==='need'&&(x.done||x.skipped))return false;
+    if(filter==='live'&&!x.live)return false;
     if(filter==='done'&&!x.done)return false;
     if(q&&!((x.title||'').toLowerCase().includes(q)||(x.dw_sku||'').toLowerCase().includes(q)||(x.mfr||'').toLowerCase().includes(q)))return false;
     return true;
@@ -108,7 +111,7 @@ function render(){
   grid.innerHTML = list.length? '' : '<div class="empty">Nothing here — switch filter or clear search.</div>';
   for(const x of list){
     const el=document.createElement('div'); el.className='card'+(x.done?' done':'');
-    const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.done?'<span class="badge b-done">✓ DONE</span>':'<span class="badge b-need">NEEDS</span>';
+    const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.live?'<span class="badge b-live">● LIVE</span>':x.done?'<span class="badge b-done">✓ PHOTO</span>':'<span class="badge b-need">NEEDS</span>';
     const thumb=x.photo?`style="background-image:url('${x.photo}')"`:'';
     el.innerHTML=`
       <div class="thumb" ${thumb}>${x.photo?'':'▦'}${badge}</div>
@@ -120,10 +123,10 @@ function render(){
           <span class="price ${priced(x)?'':'no'}">${priced(x)?'$'+x.price:'no price'}</span>
           <span>${x.status}</span>
         </div>
-        ${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.shopify_pushed?' · on Shopify':''}</div>`:''}
+        ${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.live?' · 🟢 LIVE on store':x.shopify_pushed?(x.live_reason?(' · photo on Shopify ('+x.live_reason+')'):' · photo on Shopify'):''}</div>`:''}
         ${x.push_err?`<div class="when" style="color:var(--red)">${x.push_err}</div>`:''}
         <div class="actions">
-          <label class="shoot">${x.done?'Retake':'📷 Photograph'}<input type="file" accept="image/*" capture="environment"></label>
+          <label class="shoot">${x.done?'📷 Retake':'📷 Open Camera'}<input type="file" accept="image/*" capture="environment"></label>
           ${x.done?'':'<button class="skip">Skip</button>'}
         </div>
       </div>`;
@@ -149,8 +152,8 @@ async function shoot(x,file){
     const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl})});
     const d=await r.json();
-    if(d.ok){ Object.assign(x,{done:true,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err});
-      toast(d.shopify_pushed?('✓ '+x.dw_sku+' → Shopify'):('Saved '+x.dw_sku+(d.push_err?' (Shopify push failed)':''))); render();
+    if(d.ok){ Object.assign(x,{done:true,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err,live:d.live,live_reason:d.live_reason});
+      toast(d.live?('🟢 '+x.dw_sku+' is LIVE on the store!'):d.shopify_pushed?('✓ Photo on Shopify · '+x.dw_sku+(d.live_reason?(' (needs '+d.live_reason.replace('needs ','')+')'):'')):('Saved '+x.dw_sku+(d.push_err?' (push failed)':''))); render();
     } else toast('Error: '+(d.err||'failed'));
   }catch(e){toast('Upload failed — check connection');}
 }
diff --git a/server.js b/server.js
index e5970a9..8fe868e 100644
--- a/server.js
+++ b/server.js
@@ -41,25 +41,64 @@ const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
 let progress = loadJSON(PROGRESS_FILE, {});
 const saveProgress = () => saveJSON(PROGRESS_FILE, progress);
 
-function shopifyAttachImage(productId, dwSku, base64) {
+function shopifyReq(method, path, payload) {
   return new Promise((resolve) => {
-    if (!TOKEN) return resolve({ ok: false, err: 'no SHOPIFY_ADMIN_TOKEN' });
-    const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
-    const body = JSON.stringify({ image: { attachment: base64, filename: `${safe}.jpg` } });
+    if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
+    const data = payload ? JSON.stringify(payload) : null;
     const req = https.request({
-      host: SHOP, path: `/admin/api/${API}/products/${productId}/images.json`, method: 'POST',
-      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
+      host: SHOP, path: `/admin/api/${API}${path}`, 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', () => {
-        if (res.statusCode >= 200 && res.statusCode < 300) { try { const j = JSON.parse(d); resolve({ ok: true, image_id: j.image && j.image.id }); } catch (e) { resolve({ ok: true }); } }
-        else resolve({ ok: false, err: `HTTP ${res.statusCode}: ${d.slice(0, 180)}` });
-      });
+      res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b, raw: d }); });
     });
-    req.on('error', e => resolve({ ok: false, err: e.message }));
-    req.write(body); req.end();
+    req.on('error', e => resolve({ status: 0, err: e.message }));
+    if (data) req.write(data); req.end();
   });
 }
+function gql(query, variables) {
+  return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }).then(r => (r.body || {}));
+}
+
+function shopifyAttachImage(productId, dwSku, base64) {
+  const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
+  return shopifyReq('POST', `/products/${productId}/images.json`, { image: { attachment: base64, filename: `${safe}.jpg` } })
+    .then(r => (r.status >= 200 && r.status < 300)
+      ? { ok: true, image_id: r.body && r.body.image && r.body.image.id }
+      : { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 160)}` });
+}
+
+// After a photo lands, make the SKU live IF it passes the activation gate
+// (now has image + a real price + width + a description). Else stays draft.
+async function shopifyActivateIfReady(productId) {
+  const q = `query($id:ID!){ product(id:$id){ status descriptionHtml
+      imgs:images(first:1){edges{node{id}}}
+      w:metafield(namespace:"global",key:"width"){value} w2:metafield(namespace:"custom",key:"width"){value}
+      variants(first:5){edges{node{ title sku price }}} } }`;
+  const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
+  const p = d && d.data && d.data.product;
+  if (!p) return { live: false, reason: 'lookup failed' };
+  const hasImg = p.imgs.edges.length > 0;
+  const hasWidth = !!((p.w && p.w.value) || (p.w2 && p.w2.value));
+  const hasDesc = !!(p.descriptionHtml && p.descriptionHtml.replace(/<[^>]+>/g, '').trim().length > 20);
+  const roll = p.variants.edges.map(e => e.node).find(v => !(v.sku || '').endsWith('-Sample') && (v.title || '').toLowerCase() !== 'sample');
+  const hasPrice = roll && parseFloat(roll.price) > 4.25;
+  if (!(hasImg && hasWidth && hasDesc && hasPrice)) {
+    const miss = [!hasImg && 'image', !hasPrice && 'price', !hasWidth && 'width', !hasDesc && 'description'].filter(Boolean);
+    return { live: false, reason: 'needs ' + miss.join(' + ') };
+  }
+  if (p.status === 'ACTIVE') return { live: true, already: true };
+  // set active
+  const up = await shopifyReq('PUT', `/products/${productId}.json`, { product: { id: productId, status: 'active' } });
+  if (up.status < 200 || up.status >= 300) return { live: false, reason: `activate HTTP ${up.status}` };
+  // publish to all sales channels
+  const pubs = await gql('{ publications(first:50){edges{node{id}}} }');
+  const ids = ((pubs.data && pubs.data.publications && pubs.data.publications.edges) || []).map(e => ({ publicationId: e.node.id }));
+  await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id,input:$in){ userErrors{message} } }`,
+    { id: `gid://shopify/Product/${productId}`, in: ids });
+  return { live: true, channels: ids.length };
+}
 
 function checkAuth(req) {
   const h = req.headers.authorization || '';
@@ -73,7 +112,8 @@ function buildQueue() {
   return q.map(item => {
     const pr = progress[item.dw_sku] || {};
     return { ...item, done: !!pr.done, skipped: !!pr.skipped, photo: pr.photo || null, ts: pr.ts || null,
-             shopify_pushed: !!pr.shopify_pushed, push_err: pr.push_err || null };
+             shopify_pushed: !!pr.shopify_pushed, push_err: pr.push_err || null,
+             live: !!pr.live, live_reason: pr.live_reason || null };
   });
 }
 
@@ -121,13 +161,19 @@ const server = http.createServer((req, res) => {
       fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
       const entry = progress[dw_sku] || {};
       entry.done = true; entry.skipped = false; entry.photo = `/photos/${fname}`; entry.ts = new Date().toISOString();
-      // push to Shopify draft (default on)
+      entry.live = false; entry.live_reason = null;
+      // push the photo onto the Shopify product (lands on Shopify CDN), then make the SKU live if ready
       if (push !== false && product_id) {
         const r = await shopifyAttachImage(product_id, dw_sku, b64);
         entry.shopify_pushed = r.ok; entry.push_err = r.ok ? null : r.err;
+        if (r.ok) {
+          const a = await shopifyActivateIfReady(product_id);
+          entry.live = !!a.live; entry.live_reason = a.reason || null;
+        }
       }
       progress[dw_sku] = entry; saveProgress();
-      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, shopify_pushed: entry.shopify_pushed, push_err: entry.push_err });
+      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, shopify_pushed: entry.shopify_pushed,
+        push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
     });
     return;
   }

← 2b07e49 DW Pattern Photo Capture: mobile-first (iPhone/iPad) camera  ·  back to Dw Photo Capture  ·  Add Photoshop-style photo editor (brightness/contrast/satura dcdf1c3 →