[object Object]

← 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

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 →