← back to Dw Photo Capture
Camera-first capture flow + 10-photo/6-video gallery: pill→camera→details-after-scan; Add mode creates draft, Update mode resolves existing SKU (shopifySearch fallback) and pushes photos+videos LIVE to Shopify; /api/resolve-product endpoint
4a51d5b6892d0541bef8058d99add57b890e467d · 2026-07-06 20:34:52 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit 4a51d5b6892d0541bef8058d99add57b890e467d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 6 20:34:52 2026 -0700
Camera-first capture flow + 10-photo/6-video gallery: pill→camera→details-after-scan; Add mode creates draft, Update mode resolves existing SKU (shopifySearch fallback) and pushes photos+videos LIVE to Shopify; /api/resolve-product endpoint
---
public/index.html | 125 ++++++++++++++++++++++++++++++++++++++----------------
server.js | 22 ++++++++++
2 files changed, 110 insertions(+), 37 deletions(-)
diff --git a/public/index.html b/public/index.html
index f27e10f..1a5322a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -165,6 +165,17 @@
@keyframes micpulse{50%{background:#a53a3a}}
.add-voice{display:block;width:100%;box-sizing:border-box;margin:0 0 12px;padding:11px;border:1px solid var(--line);border-radius:10px;background:#151310;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
.add-voice:active{transform:scale(.99)}
+ .media-sub{font-size:12px;color:#b7ad98;margin-bottom:10px;min-height:0}
+ .media-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:10px}
+ .media-grid:empty{display:none}
+ .media-cell{position:relative;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;overflow:hidden;border:1px solid var(--line)}
+ .media-cell video{width:100%;height:100%;object-fit:cover}
+ .media-cell .badge{position:absolute;left:4px;bottom:4px;font-size:15px;filter:drop-shadow(0 1px 2px #000)}
+ .media-cell .rm{position:absolute;top:2px;right:2px;width:22px;height:22px;border:none;border-radius:50%;background:rgba(0,0,0,.65);color:#fff;font-size:14px;line-height:22px;cursor:pointer;padding:0}
+ .media-add{display:flex;gap:10px;margin-bottom:12px}
+ .media-btn{flex:1;padding:14px 6px;border:1px dashed var(--line);border-radius:12px;background:#0f0e0b;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
+ .media-btn:active{transform:scale(.98)}.media-btn:disabled{opacity:.4;cursor:not-allowed}
+ .media-btn span{opacity:.65;font-weight:400;font-size:12px}
.add-spec{display:flex;justify-content:space-between;gap:12px;font-size:12.5px;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.05)}
.add-spec span{color:#9a917d}.add-spec b{color:var(--ink);text-align:right}
/* onload chooser — three white pills on black */
@@ -390,15 +401,15 @@
<div class="samp" id="addModal" hidden>
<div class="samp-card">
<button class="samp-x" id="addClose">✕</button>
- <div class="samp-pat" style="margin-bottom:10px">Add new item</div>
- <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 class="samp-pat" id="addTitle" style="margin-bottom:4px">Add new item</div>
+ <div class="media-sub" id="addTarget"></div>
+ <div class="media-grid" id="addMedia"></div>
+ <div class="media-add">
+ <button class="media-btn" id="addPhotoBtn">📷 Photo <span id="cPhoto">0/10</span></button>
+ <button class="media-btn" id="addVideoBtn">🎥 Video <span id="cVideo">0/6</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>
+ <input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
+ <input type="file" accept="video/*" capture="environment" id="addVideoInput" 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>
@@ -1427,46 +1438,83 @@ $('#fbBackInput').addEventListener('change',e=>fbRead(e.target.files[0],true));
$('#fbFrontInput').addEventListener('change',e=>fbRead(e.target.files[0],false));
$('#fbGo').addEventListener('click',fbIdentify);
-// ── Add new item: vendor/vid dropdown → Shopify DRAFT + dw_unified staging (preview then commit) ──
+// ── Add / Update item: capture up to 10 photos + 6 videos → Shopify (draft-create OR live-update) ──
+const MAX_PHOTOS=10, MAX_VIDEOS=6;
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(); }
+let _media=[]; // {type:'photo'|'video', dataUrl, name}
+let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false;
+function mediaCounts(){ return { p:_media.filter(m=>m.type==='photo').length, v:_media.filter(m=>m.type==='video').length }; }
+function renderMedia(){ const c=mediaCounts();
+ $('#cPhoto').textContent=c.p+'/'+MAX_PHOTOS; $('#cVideo').textContent=c.v+'/'+MAX_VIDEOS;
+ $('#addPhotoBtn').disabled=c.p>=MAX_PHOTOS; $('#addVideoBtn').disabled=c.v>=MAX_VIDEOS;
+ $('#addMedia').innerHTML=_media.map((m,i)=> m.type==='video'
+ ? `<div class="media-cell"><video src="${m.dataUrl}" muted playsinline preload="metadata"></video><span class="badge">🎥</span><button class="rm" data-rm="${i}">✕</button></div>`
+ : `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
+ $('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
+function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; $('#addSpecs').innerHTML=''; renderMedia(); }
+function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
+async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
+ if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
+ const url=await downscale(file,1400); _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
+ if(!_extracted){ _extracted=true; addExtract(); } // first photo = the scan → read specs
+ } else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
+ if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
+ const dataUrl=await fileToDataUrl(file); _media.push({type:'video',dataUrl,name:file.name||'clip.mp4'}); renderMedia(); } }
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){ addResetPhotos(); ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; addResetMedia();
+ ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
+ $('#addTitle').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
+ $('#addTarget').textContent = _addMode==='update' ? 'Shoot the label to find the SKU, then add photos + videos.' : '';
+ $('#addPreview').textContent = _addMode==='update' ? '⬆ Push media to SKU (LIVE)' : '👁 Preview';
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){ 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; }
+ $('#addModal').hidden=false; document.body.style.overflow='hidden';
+ loadVendors().then(()=>{ if(pref&&pref.vendor) $('#addVendor').value=pref.vendor; });
+ if(opts.camera) setTimeout(()=>$('#addPhotoInput').click(),250); // onload → go straight to camera
+}
+function addPhotosList(){ return _media.filter(m=>m.type==='photo').map(m=>m.dataUrl); }
+function addVideosList(){ return _media.filter(m=>m.type==='video'); }
+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, photos:addPhotosList(), commit:!!commit }); }
+async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0;
+ for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
+ try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; }catch(e){} }
+ return ok; }
+async function addPreview(){ if(_addMode==='update') return addUpdate();
+ 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();
- if(r.duplicate){ $('#addNote').textContent='⚠ '+r.err; return; }
+ if(r.duplicate){ $('#addNote').innerHTML='⚠ '+r.err+' — tap <b>⌂ → Update SKU</b> to add media to it live.'; return; }
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); return; }
- const pv=r.preview; $('#addNote').textContent='Preview — nothing created yet.';
- $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${pv.title}</b><br><small>${[pv.vendor,pv.dw_sku,pv.mfr,pv.color,pv.price?('$'+pv.price):''].filter(Boolean).join(' · ')} · <b>DRAFT</b></small></div></div><button class="samp-print" id="addCommit" style="margin-top:10px">✅ Create draft in Shopify + catalog</button>`;
+ const pv=r.preview, c=mediaCounts(); $('#addNote').textContent='Preview — nothing created yet.';
+ $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${pv.title}</b><br><small>${[pv.vendor,pv.dw_sku,pv.mfr,pv.color,pv.price?('$'+pv.price):''].filter(Boolean).join(' · ')} · ${c.p} photo(s) · ${c.v} video(s) · <b>DRAFT</b></small></div></div><button class="samp-print" id="addCommit" style="margin-top:10px">✅ Create draft + upload media</button>`;
$('#addCommit').addEventListener('click',addCommit);
}
async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
- if(r.ok){ $('#addNote').innerHTML=`✅ Created DRAFT · ${r.dw_sku} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku); }
- else { $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; }
+ if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
+ let vmsg=''; if(addVideosList().length && r.product_id){ const n=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${n} video(s)`; }
+ $('#addNote').innerHTML=`✅ Created DRAFT · ${r.dw_sku}${vmsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
+}
+// UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
+async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
+ try{ const r=await(await fetch('/api/resolve-product?mfr='+encodeURIComponent(mfr))).json();
+ if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
+ else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No existing SKU matched “'+mfr+'”. Fix the mfr# or use Add New SKU.'; }
+ }catch(e){} }
+async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
+ if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label / fix the mfr#.'; return; }
+ const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
+ $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
+ if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
+ const vn=addVideosList().length ? await uploadVideos(_updatePid,_updateSku) : 0;
+ $('#addNote').innerHTML=`✅ LIVE: ${added} photo(s) + ${vn} video(s) → ${_updateSku}`; toast('✅ Updated '+_updateSku+' live');
}
$('#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; });
-[['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]));
-});
+$('#addPhotoBtn').addEventListener('click',()=>$('#addPhotoInput').click());
+$('#addVideoBtn').addEventListener('click',()=>$('#addVideoInput').click());
+$('#addPhotoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('photo',f)); });
+$('#addVideoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('video',f)); });
$('#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).
@@ -1477,7 +1525,9 @@ async function addExtract(){ if(!_addPhoto)return; $('#addNote').textContent='
if(r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===r.vendor_matched.toLowerCase()); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; } }
const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
$('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
- $('#addNote').textContent='✓ Imported from label'; addPromptMissing();
+ $('#addNote').textContent='✓ Imported from label';
+ if(_addMode==='update'){ await addResolveUpdate(); } // find the existing product to update
+ else addPromptMissing();
}catch(e){ $('#addNote').textContent=''; }
}
// speak a prompt out loud, then listen for the answer (the on-screen field stays usable too)
@@ -1521,8 +1571,9 @@ document.querySelectorAll('#addModal .mic-btn').forEach(b=>b.addEventListener('c
// ── onload chooser: Add New SKU · Update SKU · Check Sample In ──
function routeHome(act){ $('#homeScreen').hidden=true;
- if(act==='add') openAddModal();
- else if(act==='update') openLiveScan();
+ // onload → straight to the camera; details show after the first scan
+ if(act==='add') openAddModal(null,{mode:'add',camera:true});
+ else if(act==='update') openAddModal(null,{mode:'update',camera:true});
else if(act==='check') openFbModal();
// 'skip' → just show the catalog
}
diff --git a/server.js b/server.js
index 4eeba82..a5fe4f5 100644
--- a/server.js
+++ b/server.js
@@ -1156,6 +1156,28 @@ const appHandler = (req, res) => {
return;
}
+ // Resolve an EXISTING Shopify product to update (used by the "Update SKU" media flow):
+ // match the scanned mfr# (or dw_sku) against the in-RAM catalog → return its product_id.
+ if (u.pathname === '/api/resolve-product' && req.method === 'GET') {
+ const mfr = (u.searchParams.get('mfr') || '').trim();
+ const sku = (u.searchParams.get('sku') || '').trim();
+ const q = mfr || sku;
+ const done = hit => send(res, 200, { ok: !!hit, found: !!hit, product_id: hit && hit.product_id, dw_sku: hit && hit.dw_sku, title: hit && hit.title, image: hit && hit.image, status: hit && hit.status });
+ // 1) fast in-RAM catalog (the work queue)
+ let hit = null;
+ if (mfr) { const n = nmfr(mfr); hit = CATALOG.find(x => x.mfr && nmfr(x.mfr) === n); }
+ if (!hit && sku) { const s = sku.toUpperCase(); hit = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s); }
+ if (hit || !q) return done(hit);
+ // 2) fall back to a LIVE Shopify search (covers the whole ~169k store, not just the queue)
+ shopifySearch(q).then(items => {
+ const n = nmfr(q), s = q.toUpperCase();
+ const best = items.find(x => x.mfr && nmfr(x.mfr) === n)
+ || items.find(x => (x.dw_sku || '').toUpperCase() === s) || items[0] || null;
+ done(best);
+ }).catch(() => done(null));
+ return;
+ }
+
// "BEST ID": identify a scanned code against the whole unified catalog (crossref 267k +
// images) BEFORE Shopify/FileMaker. Returns the canonical identity + FileMaker search keys.
if (u.pathname === '/api/identify-unified' && req.method === 'GET') {
← 29237c6 Sticker loop web side LIVE: web_print_request field placed +
·
back to Dw Photo Capture
·
Video transcode poll: /api/media-status reads Shopify video aa3a6ce →