← 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
M public/index.htmlM server.js
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 →