← back to Dw Photo Capture
Photo capture: two-photo capture + mfr# identity gate (front-OCR/back-OCR/manual)
4cbb39e8b2b4142c0dc4a8a41c4baf7aa9260c9a · 2026-09-18 14:00:08 -0700 · steve
- New-SKU capture uses explicit FRONT (pattern face, featured img pos1) + BACK
(label, OCR'd, pos2) tiles, phone-friendly, matching the XL styling
- Identity gate: create allowed only with a FRONT photo AND an mfr# from one of
{front-OCR, back-OCR, manual}. Back photo is OPTIONAL when the front already
yields the code (priority: front-OCR -> back-OCR -> manual); UI tells the user
which after the front shot. Never creates an item with no product photo/identity.
- Backend defense-in-depth: require_two -> front photo required + real mfr#.
- Verified in headless Chrome: empty->block, front-only->block, front+manual->ok,
front-OCR->ok(back optional), front+back->ok; backend blocks no-photo. 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCq3132eTmJESAKqJBM8FN
Files touched
M public/index.htmlM server.js
Diff
commit 4cbb39e8b2b4142c0dc4a8a41c4baf7aa9260c9a
Author: steve <steve@designerwallcoverings.com>
Date: Fri Sep 18 14:00:08 2026 -0700
Photo capture: two-photo capture + mfr# identity gate (front-OCR/back-OCR/manual)
- New-SKU capture uses explicit FRONT (pattern face, featured img pos1) + BACK
(label, OCR'd, pos2) tiles, phone-friendly, matching the XL styling
- Identity gate: create allowed only with a FRONT photo AND an mfr# from one of
{front-OCR, back-OCR, manual}. Back photo is OPTIONAL when the front already
yields the code (priority: front-OCR -> back-OCR -> manual); UI tells the user
which after the front shot. Never creates an item with no product photo/identity.
- Backend defense-in-depth: require_two -> front photo required + real mfr#.
- Verified in headless Chrome: empty->block, front-only->block, front+manual->ok,
front-OCR->ok(back optional), front+back->ok; backend blocks no-photo. 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCq3132eTmJESAKqJBM8FN
---
public/index.html | 74 ++++++++++++++++++++++++++++++++++++++++++++++++-------
server.js | 9 +++++++
2 files changed, 74 insertions(+), 9 deletions(-)
diff --git a/public/index.html b/public/index.html
index fe6e41f..548c58a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -255,6 +255,18 @@
table.pv-fm td{border-top:1px solid var(--line);padding:3px 4px;color:#cfc4aa;vertical-align:top}
table.pv-fm td:first-child{color:#8f866f;white-space:nowrap;width:44%}
.pv-flags{margin-top:8px;padding:8px 10px;background:#241a0c;border:1px solid #6b551f;border-radius:9px;color:#e8c987;font-size:12.5px;line-height:1.5}
+ /* TWO-PHOTO capture — big phone-friendly front/back tiles */
+ .two-shot{display:flex;gap:10px;margin:4px 0 8px}
+ .shot-btn{position:relative;flex:1;min-height:92px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
+ background:#0f0e0b;border:2px dashed var(--line);border-radius:14px;color:var(--ink);cursor:pointer;padding:10px}
+ .shot-btn .shot-ico{font-size:26px;line-height:1}
+ .shot-btn b{font-size:17px}
+ .shot-btn small{font-size:11.5px;color:#9a917d}
+ .shot-btn.have{border-style:solid;border-color:#3f7d4f;background:#10160f}
+ .shot-btn.have .shot-ck::after{content:'✓';position:absolute;top:6px;right:9px;color:#5cc177;font-size:16px;font-weight:800}
+ .shot-btn.need{border-color:#b8902f}
+ .shot-status{font-size:13px;margin:2px 0 8px;min-height:16px;color:#c8ba97}
+ .shot-status.block{color:#e6a23a}
.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)}
@@ -525,11 +537,20 @@
<select id="addVendor" class="xl-vendor"><option value="">Loading vendors…</option></select>
<div class="xl-vendor-meta" id="xlVendorMeta"></div>
</div>
+ <!-- TWO-PHOTO gate: FRONT (pattern face) + BACK (label/SKU, OCR'd). Create is blocked until
+ FRONT exists AND (BACK exists OR the mfr#/details are entered manually). -->
+ <div class="two-shot">
+ <button class="shot-btn" id="frontBtn"><span class="shot-ico">📷</span><b>Front</b><small>pattern face</small><i class="shot-ck" id="frontCk"></i></button>
+ <button class="shot-btn" id="backBtn"><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
+ </div>
+ <div class="shot-status" id="shotStatus"></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="addPhotoBtn">➕ More photo <span id="cPhoto">0</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="frontInput" hidden>
+ <input type="file" accept="image/*" capture="environment" id="backInput" 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="addMeasure">📐 Measure real size (place a card by the goods)</button>
@@ -1759,7 +1780,7 @@ function renderMedia(){ const c=mediaCounts();
? `<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; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); }
+function addResetMedia(){ _media=[]; _addPhoto=null; _frontPhoto=null; _backPhoto=null; _frontHasCode=false; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); renderShots(); }
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; }
@@ -1769,6 +1790,30 @@ async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
} 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(); } }
+// ── IDENTITY gate: a new SKU must have an mfr# from one of {front-OCR, back-OCR, manual} ──────────
+// FRONT (pattern face) is the featured image and is OCR'd first. If the front already carries the
+// code, the BACK photo is OPTIONAL; otherwise a BACK label photo (OCR'd) OR manual mfr# is required.
+let _frontPhoto=null, _backPhoto=null, _frontHasCode=false;
+async function shotAdd(side,file){ if(!file)return;
+ const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again'); return; }
+ if(side==='front'){ _frontPhoto=url; _addPhoto=url; renderShots(); await addExtract(url,'front'); } // priority 1: front-OCR
+ else { _backPhoto=url; renderShots(); await addExtract(url,'back'); } // priority 2: back-OCR
+ renderShots(); addDedupCheck&&addDedupCheck(); }
+// Ordered photo list the backend receives: FRONT (pos1) then BACK (pos2) then any extras.
+function addPhotosList(){ return [_frontPhoto,_backPhoto].filter(Boolean).concat(_media.filter(m=>m.type==='photo').map(m=>m.dataUrl)); }
+// Create is allowed only with FRONT photo + an mfr# identity (front-OCR | back-OCR | manual).
+function twoPhotoGate(){ const mfr=($('#addMfr').value||'').trim();
+ if(!_frontPhoto) return { ok:false, msg:'📷 Take the FRONT (pattern) photo to continue.' };
+ if(!mfr) return { ok:false, msg:'🏷 No code read from the front — take the BACK (label) photo, or type the mfr#/SKU.' };
+ const src = _frontHasCode ? 'front photo — back optional' : (_backPhoto ? 'back label' : 'manual entry');
+ return { ok:true, msg:'✓ mfr# identity from '+src };
+}
+function renderShots(){
+ const mfr=($('#addMfr').value||'').trim();
+ $('#frontBtn').classList.toggle('have',!!_frontPhoto); $('#frontBtn').classList.toggle('need',!_frontPhoto);
+ // back tile only "needs" attention when there's no identity yet (front had no code AND no manual mfr)
+ $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_frontHasCode && !_backPhoto && !mfr);
+ const g=twoPhotoGate(); const s=$('#shotStatus'); if(s){ s.textContent=g.msg; s.classList.toggle('block',!g.ok); } }
let _vreg=[];
// One <option> set, applied to BOTH the sticky camera bar and the (mirrored) modal picker.
function vendorOptionsHtml(placeholder){ return `<option value="">${placeholder}</option>`+_vreg.map(v=>
@@ -1821,9 +1866,8 @@ function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _add
});
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 }); }
+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(), require_two:true, front_present:!!_frontPhoto, back_present:!!_backPhoto, id_source:(_frontHasCode?'front':(_backPhoto?'back':'manual')), commit:!!commit }); }
async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0,failed=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++; else failed++; }catch(e){ failed++; } }
@@ -1844,7 +1888,7 @@ async function pollVideoStatus(product_id, baseMsg, expected){ const started=Dat
async function addPreview(){ if(_addMode==='update') return addUpdate();
const p=addPayload(false);
if(!p.vendor){ $('#addVendor').classList.add('need'); $('#addNote').textContent='Pick a vendor first.'; return; }
- if(!p.mfr){ $('#addNote').textContent='Mfr# is required.'; return; }
+ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').textContent=g.msg; $('#shotStatus').classList.add('block'); 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').innerHTML='⚠ '+r.err+' — tap <b>⌂ → Update SKU</b> to add media to it live.'; return; }
@@ -1862,7 +1906,8 @@ async function addPreview(){ if(_addMode==='update') return addUpdate();
<button class="samp-print" id="addCommit" style="margin-top:12px">✅ Create draft + staging + FM master</button>`;
$('#addCommit').addEventListener('click',addCommit);
}
-async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
+async function addCommit(){ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').textContent=g.msg; return; }
+ 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').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
@@ -1941,6 +1986,13 @@ $('#addBtn').addEventListener('click',()=>openAddModal());
$('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
$('#addVendor').addEventListener('change',onVendorPick);
$('#stickyVendor')&&$('#stickyVendor').addEventListener('change',onStickyPick);
+// Two-photo capture wiring: dedicated FRONT (pattern) + BACK (label) tiles.
+$('#frontBtn').addEventListener('click',()=>$('#frontInput').click());
+$('#backBtn').addEventListener('click',()=>$('#backInput').click());
+$('#frontInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('front',f); });
+$('#backInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('back',f); });
+// Typing the mfr# manually is a valid identity source → re-evaluate the gate live.
+$('#addMfr').addEventListener('input',()=>renderShots());
$('#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)); });
@@ -1948,11 +2000,15 @@ $('#addVideoInput').addEventListener('change',e=>{ const files=[...e.target.file
$('#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).
-async function addExtract(){ if(!_addPhoto)return; const mySession=_addSession; $('#addNote').textContent='📷 Reading label…';
- try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:_addPhoto})})).json();
+async function addExtract(dataUrl,side){ const src=dataUrl||_addPhoto; if(!src)return; const mySession=_addSession; $('#addNote').textContent=side==='back'?'🏷 Reading label…':'📷 Reading photo…';
+ const hadMfr=($('#addMfr').value||'').trim();
+ try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:src})})).json();
if(_addSession!==mySession) return; // modal was closed/reopened mid-request — discard this stale result
- const f=r.fields||{}; _addExtracted=f; const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
+ const f=r.fields||{}; _addExtracted=Object.assign({},_addExtracted,f); const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
+ // priority-1 identity: the FRONT photo already carried the code → back photo becomes optional
+ if(side==='front' && !hadMfr && ($('#addMfr').value||'').trim()) _frontHasCode=true;
+ renderShots();
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('')) : '';
diff --git a/server.js b/server.js
index c74218d..5855cd3 100644
--- a/server.js
+++ b/server.js
@@ -2570,6 +2570,15 @@ function buildFmMaster(p, mint, vreg) {
async function createNewItem(p, b64, dryRun) {
const mfr = String(p.mfr || '').trim(); const vendor = String(p.vendor || '').trim();
if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
+ // TWO-PHOTO / identity rail (defense-in-depth behind the UI gate): a new-SKU capture must carry a
+ // FRONT photo, and its mfr# identity must come from front-OCR, back-OCR, or manual entry (mfr, above).
+ // Back is optional only when the front already yielded the code — the UI enforces which; here we just
+ // guarantee a front image + a real mfr# so an item can never be created with no product photo/identity.
+ if (p.require_two) {
+ const nPhotos = Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0);
+ if (nPhotos < 1) return { ok: false, err: 'front (pattern) photo required' };
+ if (!p.back_present && !mfr) return { ok: false, err: 'no back photo and no manual mfr# — capture the back label or enter the mfr#/SKU' };
+ }
// Resolve the chosen vendor from the canonical registry (drives DW# + Shopify vendor + FM vid).
const registry = await getVendorsRegistry().catch(() => []);
let vreg = registry.find(v => (v.vid && p.vid && v.vid === p.vid))
← c03988d Photo capture: post each metafield with its store-defined ty
·
back to Dw Photo Capture
·
Photo capture: installable PWA — DW-branded home-screen icon b99e4d9 →