← back to Dw Photo Capture
Add 3 photo slots to Add-item (Front·pattern / Back·label / Detail): label photo drives spec extraction, all 3 attach as ordered Shopify product images (front primary)
c0cf2c5e758d23046e2c5abb021b29e966c7ed45 · 2026-07-06 20:00:10 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit c0cf2c5e758d23046e2c5abb021b29e966c7ed45
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 6 20:00:10 2026 -0700
Add 3 photo slots to Add-item (Front·pattern / Back·label / Detail): label photo drives spec extraction, all 3 attach as ordered Shopify product images (front primary)
---
public/index.html | 32 ++++++++++++++++++++++++++------
server.js | 12 +++++++++---
2 files changed, 35 insertions(+), 9 deletions(-)
diff --git a/public/index.html b/public/index.html
index 8ddb839..f27e10f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -391,8 +391,14 @@
<div class="samp-card">
<button class="samp-x" id="addClose">✕</button>
<div class="samp-pat" style="margin-bottom:10px">Add new item</div>
- <button class="fb-tile" id="addPhotoTile" style="margin-bottom:12px;flex:none;width:100%"><div class="fb-thumb" id="addThumb" style="aspect-ratio:auto;height:64px;width:64px">📷</div><span>Photo (optional)</span></button>
- <input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
+ <div class="fb-tiles">
+ <button class="fb-tile" id="addFrontTile"><div class="fb-thumb" id="addFrontThumb">🎨</div><span>Front · pattern</span></button>
+ <button class="fb-tile" id="addBackTile"><div class="fb-thumb" id="addBackThumb">📄</div><span>Back · label</span></button>
+ <button class="fb-tile" id="addDetailTile"><div class="fb-thumb" id="addDetailThumb">🔍</div><span>Detail</span></button>
+ </div>
+ <input type="file" accept="image/*" capture="environment" id="addFrontInput" hidden>
+ <input type="file" accept="image/*" capture="environment" id="addBackInput" hidden>
+ <input type="file" accept="image/*" capture="environment" id="addDetailInput" hidden>
<button class="add-voice" id="addVoice">🎤 Voice-fill everything that's missing</button>
<div class="add-field"><label>Mfr # / SKU</label><div class="in-mic"><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"><button class="mic-btn" data-mic="addMfr" data-kind="sku" title="Speak the SKU / mfr number">🎤</button></div></div>
<div class="add-field"><label>Vendor</label><div class="in-mic"><select id="addVendor"><option value="">Loading…</option></select><button class="mic-btn" data-mic="addVendor" data-kind="vendor" title="Speak the vendor name">🎤</button></div></div>
@@ -1423,11 +1429,23 @@ $('#fbGo').addEventListener('click',fbIdentify);
// ── Add new item: vendor/vid dropdown → Shopify DRAFT + dw_unified staging (preview then commit) ──
let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
+let _addPhotos={front:null,back:null,detail:null}; // up to 3: front pattern, back label, detail
+function addResetPhotos(){ _addPhoto=null; _addPhotos={front:null,back:null,detail:null};
+ [['addFrontThumb','🎨'],['addBackThumb','📄'],['addDetailThumb','🔍']].forEach(([id,ic])=>{ const t=$('#'+id); t.style.backgroundImage=''; t.textContent=ic; t.closest('.fb-tile').classList.remove('set'); });
+ $('#addSpecs').innerHTML=''; }
+// slot photo → store, thumb it, and (for the label/back or first photo) run spec extraction off it
+async function addPhotoSet(slot,file){ if(!file)return; const url=await downscale(file,1400);
+ _addPhotos[slot]=url; const map={front:'addFrontThumb',back:'addBackThumb',detail:'addDetailThumb'}; const t=$('#'+map[slot]);
+ t.style.backgroundImage=`url('${url}')`; t.textContent=''; t.closest('.fb-tile').classList.add('set');
+ // best OCR source for specs = the label (back); else whichever photo we have. Front is the primary image.
+ _addPhoto = _addPhotos.back || _addPhotos.front || _addPhotos.detail;
+ if(slot==='back' || (slot==='front' && !_addPhotos.back) || (slot==='detail' && !_addPhotos.back && !_addPhotos.front)) addExtract(); }
async function loadVendors(){ if(_vendorsLoaded) return; try{ const r=await(await fetch('/api/vendors')).json(); $('#addVendor').innerHTML='<option value="">— pick vendor —</option>'+(r.vendors||[]).map(v=>`<option value="${v.vendor}" data-vid="${v.vid||''}">${v.vendor}${v.vid?(' ('+v.vid+')'):''}</option>`).join(''); _vendorsLoaded=true; }catch(e){} }
-function openAddModal(pref){ _addPhoto=null; $('#addThumb').style.backgroundImage=''; $('#addThumb').textContent='📷'; ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+function openAddModal(pref){ addResetPhotos(); ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
if(pref){ if(pref.mfr)$('#addMfr').value=pref.mfr; if(pref.color)$('#addColor').value=pref.color; if(pref.name)$('#addName').value=pref.name; }
$('#addModal').hidden=false; document.body.style.overflow='hidden'; loadVendors().then(()=>{ if(pref&&pref.vendor) $('#addVendor').value=pref.vendor; }); }
-function addPayload(commit){ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:_addPhoto, commit:!!commit }); }
+function addPayload(commit){ const photos=[_addPhotos.front,_addPhotos.back,_addPhotos.detail].filter(Boolean);
+ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:(photos[0]||_addPhoto), photos, commit:!!commit }); }
async function addPreview(){ const p=addPayload(false); if(!p.mfr||!p.vendor){ $('#addNote').textContent='Mfr# and Vendor are required.'; return; }
$('#addNote').textContent='Previewing…'; $('#addResult').innerHTML='';
const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)})).json();
@@ -1445,8 +1463,10 @@ async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNot
$('#addBtn').addEventListener('click',()=>openAddModal());
$('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
$('#addVendor').addEventListener('change',e=>{ const o=e.target.selectedOptions[0]; if(o&&o.dataset.vid&&!$('#addVid').value) $('#addVid').value=o.dataset.vid; });
-$('#addPhotoTile').addEventListener('click',()=>$('#addPhotoInput').click());
-$('#addPhotoInput').addEventListener('change',async e=>{ const f=e.target.files[0]; if(!f)return; _addPhoto=await downscale(f,1400); $('#addThumb').style.backgroundImage=`url('${_addPhoto}')`; $('#addThumb').textContent=''; addExtract(); });
+[['addFrontTile','addFrontInput','front'],['addBackTile','addBackInput','back'],['addDetailTile','addDetailInput','detail']].forEach(([tile,input,slot])=>{
+ $('#'+tile).addEventListener('click',()=>$('#'+input).click());
+ $('#'+input).addEventListener('change',e=>addPhotoSet(slot,e.target.files[0]));
+});
$('#addPreview').addEventListener('click',addPreview);
// import AS MUCH INFO AS POSSIBLE from the label photo → prefill every field, then VOICE ONLY as
// needed: if a required field is still blank, the app speaks the prompt + listens (dropdown stays).
diff --git a/server.js b/server.js
index fbec075..4eeba82 100644
--- a/server.js
+++ b/server.js
@@ -1144,7 +1144,12 @@ const appHandler = (req, res) => {
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!TOKEN) return send(res, 200, { ok: false, err: 'no Shopify token' });
- const b64 = p.dataUrl ? p.dataUrl.replace(/^data:image\/\w+;base64,/, '') : null;
+ const strip = s => s ? s.replace(/^data:image\/\w+;base64,/, '') : null;
+ // up to 3 photos (front pattern / back label / detail); front is the primary Shopify image.
+ const photos64 = Array.isArray(p.photos) && p.photos.length ? p.photos.map(strip).filter(Boolean)
+ : (p.dataUrl ? [strip(p.dataUrl)].filter(Boolean) : []);
+ p._photos64 = photos64;
+ const b64 = photos64[0] || null;
try { send(res, 200, await createNewItem(p, b64, p.commit !== true)); }
catch (e) { send(res, 500, { ok: false, err: e.message }); }
});
@@ -1639,7 +1644,7 @@ async function createNewItem(p, b64, dryRun) {
const name = String(p.name || '').trim(); const color = String(p.color || '').trim();
const price = p.price && +p.price > 0 ? String(p.price) : null;
const title = [name || mfr, color, '|', vendor].filter(Boolean).join(' ').replace(' | ', ' | ');
- const preview = { title, dw_sku: dwsku, mfr, vendor, vid: p.vid || null, color, price, status: 'draft', duplicate_of: dup ? (dup.dw_sku || dup.product_id) : null };
+ const preview = { title, dw_sku: dwsku, mfr, vendor, vid: p.vid || null, color, price, status: 'draft', photos: Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0), duplicate_of: dup ? (dup.dw_sku || dup.product_id) : null };
if (dup) return { ok: false, duplicate: true, preview, err: `mfr# ${mfr} already exists (${dup.dw_sku || dup.product_id}) — add a photo to it instead` };
if (dryRun) return { ok: true, dryRun: true, preview };
try {
@@ -1651,7 +1656,8 @@ async function createNewItem(p, b64, dryRun) {
const payload = { product: {
title, vendor, product_type: material, status: 'draft', tags: ['new-from-scan', 'display_variant', color ? ('color:' + color) : ''].filter(Boolean).join(', '),
options: [{ name: 'Size' }], variants,
- images: b64 ? [{ attachment: b64, filename: `${dwsku}.jpg` }] : [],
+ images: (Array.isArray(p._photos64) && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
+ .map((att, i) => ({ attachment: att, filename: `${dwsku}${i ? '-' + i : ''}.jpg`, position: i + 1 })),
metafields: [mf('custom', 'manufacturer_sku', mfr), mf('dwc', 'manufacturer_sku', mfr),
mf('custom', 'pattern_name', name), mf('custom', 'color', color), mf('global', 'Brand', vendor),
mf('global', 'Collection', p.collection), mf('global', 'width', p.width), mf('global', 'length', p.roll_length),
← eae81b8 Add per-field 🎤 mic buttons on every Add-item field (dictat
·
back to Dw Photo Capture
·
Sticker loop web side LIVE: web_print_request field placed + 29237c6 →