[object Object]

← back to Dw Photo Capture

Capture full wallcovering spec set on scan (repeat/width/roll length/match/substrate/how-sold/collection/price code) → metafields + read-only specs panel in add-item

3fcbec54058b2f5533547eb5470eee9f7b3b1430 · 2026-07-06 19:45:49 -0700 · Steve Abrams

Files touched

Diff

commit 3fcbec54058b2f5533547eb5470eee9f7b3b1430
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 19:45:49 2026 -0700

    Capture full wallcovering spec set on scan (repeat/width/roll length/match/substrate/how-sold/collection/price code) → metafields + read-only specs panel in add-item
---
 data/build.json           |  5 +++--
 data/vendor_profiles.json | 26 ++++++++++++++++++++++++--
 public/index.html         | 13 +++++++++----
 server.js                 | 23 +++++++++++++++++++++--
 4 files changed, 57 insertions(+), 10 deletions(-)

diff --git a/data/build.json b/data/build.json
index 0bc6456..ffd130e 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 57,
+  "next": 58,
   "map": {
     "13917438": 55,
     "63863152": 27,
@@ -55,6 +55,7 @@
     "3d257346": 52,
     "10ee15c2": 53,
     "06d12af6": 54,
-    "64e04a8c": 56
+    "64e04a8c": 56,
+    "fa819c2b": 57
   }
 }
\ No newline at end of file
diff --git a/data/vendor_profiles.json b/data/vendor_profiles.json
index 3ea3727..45aa535 100644
--- a/data/vendor_profiles.json
+++ b/data/vendor_profiles.json
@@ -1,10 +1,10 @@
 {
   "_meta": {
-    "vendors": 180,
+    "vendors": 181,
     "products": 131515,
     "built_at": "2026-06-26T06:01:30.211846+00:00",
     "source": "catalog-bulk",
-    "updated_at": "2026-06-26T14:57:05.946Z",
+    "updated_at": "2026-07-07T00:31:29.411Z",
     "logos_attached": 19,
     "logos_fingerprinted": 26
   },
@@ -4769,6 +4769,28 @@
       "code_source": "mfr",
       "learned_scans": 0,
       "updated_at": "2026-06-26T06:01:30.211838+00:00"
+    },
+    "Test": {
+      "n": 0,
+      "aliases": [
+        "TEST"
+      ],
+      "alias_re": "TEST",
+      "prefixes": {
+        "ABC": 1
+      },
+      "numeric_share": 0,
+      "common_len": 0,
+      "examples": [
+        "ABC123"
+      ],
+      "private_label": false,
+      "source": "scan",
+      "learned_scans": 1,
+      "sku_shapes": {
+        "AAA###": 1
+      },
+      "updated_at": "2026-07-07T00:31:29.411Z"
     }
   }
 }
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index af3eef3..8dc8bba 100644
--- a/public/index.html
+++ b/public/index.html
@@ -159,6 +159,8 @@
   .add-field input,.add-field select{width:100%;box-sizing:border-box;background:#0f0e0b;border:1px solid var(--line);border-radius:8px;padding:9px;color:var(--ink);font-size:15px}
   .add-row{display:flex;gap:10px}
   .add-field input.need,.add-field select.need{border-color:#b8902f;box-shadow:0 0 0 2px rgba(184,144,47,.35)}
+  .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 */
   #homeScreen{position:fixed;inset:0;z-index:200;background:#000;display:flex;align-items:center;justify-content:center}
   #homeScreen[hidden]{display:none}
@@ -390,6 +392,7 @@
     <div class="add-field"><label>Vendor</label><select id="addVendor"><option value="">Loading…</option></select></div>
     <div class="add-row"><div class="add-field"><label>VID</label><input id="addVid" placeholder="THI"></div><div class="add-field"><label>Color</label><input id="addColor"></div></div>
     <div class="add-row"><div class="add-field"><label>Pattern name</label><input id="addName"></div><div class="add-field"><label>Price</label><input id="addPrice" type="number" inputmode="decimal"></div></div>
+    <div id="addSpecs"></div>
     <button class="samp-print" id="addPreview">👁 Preview</button>
     <div class="samp-note" id="addNote"></div>
     <div id="addResult"></div>
@@ -1413,12 +1416,12 @@ $('#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) ──
-let _addPhoto=null,_vendorsLoaded=false;
+let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
 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='';
   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 { 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){ 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 }); }
 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();
@@ -1443,9 +1446,11 @@ $('#addPreview').addEventListener('click',addPreview);
 // needed: if a required field is still blank, the app speaks the prompt + listens (dropdown stays).
 async function addExtract(){ if(!_addPhoto)return; $('#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();
-    const f=r.fields||{}, setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
-    setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.colorway); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
+    const f=r.fields||{}; _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,''));
     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=[['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate',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();
   }catch(e){ $('#addNote').textContent=''; }
 }
diff --git a/server.js b/server.js
index 8ebb719..7691b4b 100644
--- a/server.js
+++ b/server.js
@@ -1104,7 +1104,22 @@ const appHandler = (req, res) => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
       const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
-      const prompt = 'Read this wallcovering/fabric SAMPLE label and extract EVERY field you can find. Reply ONLY as compact JSON with these keys (use "" for anything not present, preserve SKU/model codes EXACTLY, do not invent): {"vendor":"","mfr_sku":"","pattern_name":"","colorway":"","collection":"","width":"","length":"","repeat":"","content":"","match":"","price":"","other":""}.';
+      const prompt = 'Read this wallcovering/fabric SAMPLE label and extract EVERY spec you can find. '
+        + 'Reply ONLY as compact JSON with EXACTLY these keys (use "" if a field is not on the label, '
+        + 'preserve SKU/model codes and numbers EXACTLY, do not invent): '
+        + '{"vendor":"<manufacturer/brand>",'
+        + '"mfr_sku":"<manufacturer number / item code>",'
+        + '"pattern_name":"<pattern or design name>",'
+        + '"color":"<colorway / color name>",'
+        + '"collection":"<collection or book name>",'
+        + '"width":"<material width, e.g. 27 in / 52 cm>",'
+        + '"roll_length":"<length of one roll or bolt, e.g. 5 yds / 11 yds / 8 m>",'
+        + '"repeat":"<pattern repeat, e.g. 25.2 in / V 64 cm>",'
+        + '"pattern_match":"<match type: straight / half-drop / random / free / reverse>",'
+        + '"substrate":"<substrate or contents / composition, e.g. non-woven, grasscloth, vinyl on paper, 100% cotton>",'
+        + '"how_sold":"<unit of sale: single roll / double roll / per yard / per panel / per meter>",'
+        + '"price":"<numeric price if shown>",'
+        + '"price_code":"<any printed price code / tier letter or code>"}.';
       const r = await geminiVision(b64, prompt, 30000);
       let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
       // map the read vendor to a known catalog vendor (fuzzy)
@@ -1634,7 +1649,11 @@ async function createNewItem(p, b64, dryRun) {
       title, vendor, product_type: 'Wallcovering', 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` }] : [],
-      metafields: [mf('custom', 'manufacturer_sku', mfr), mf('dwc', 'manufacturer_sku', mfr), mf('custom', 'pattern_name', name), mf('custom', 'color', color), mf('global', 'Brand', vendor)].filter(Boolean)
+      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),
+        mf('global', 'Pattern-Repeat', p.repeat), mf('global', 'Match', p.pattern_match), mf('global', 'Content', p.substrate),
+        mf('dwc', 'sold_by', p.how_sold), mf('custom', 'price_code', p.price_code)].filter(Boolean)
     } };
     const cr = await shopifyReq('POST', '/products.json', payload);
     if (cr.status < 200 || cr.status >= 300) return { ok: false, err: `Shopify create HTTP ${cr.status}: ${(cr.raw || '').slice(0, 160)}` };

← 264f5cc feat: max-info extract on scan + voice-only-as-needed. /api/  ·  back to Dw Photo Capture  ·  Generalize spec capture to any spec sheet or sample: read pr d548ec8 →