← back to Dw Photo Capture
feat: add new SKU to dw_unified + Shopify (draft) with vendor/vid dropdown
9a59376df69b641228c5f87df6699197a9fa9bf8 · 2026-07-06 19:15:52 -0700 · Steve Abrams
- /api/vendors: 162 real vendors + vids from vendor_catalog for the dropdown.
- /api/create-item: dryRun PREVIEW (default) → commit creates a Shopify DRAFT product (never
auto-published — go-live stays gated) + a dw_unified new_items_staging row (additive table, does
NOT touch canonical catalog); dedups on mfr#.
- '➕ New item' modal: photo(optional)+mfr+vendor dropdown(auto-fills vid)+name+color+price →
preview → 'Create draft'. Verified: 162 vendors, dryRun preview of Phillip Jeffries draft.
Files touched
M public/index.htmlM server.js
Diff
commit 9a59376df69b641228c5f87df6699197a9fa9bf8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 6 19:15:52 2026 -0700
feat: add new SKU to dw_unified + Shopify (draft) with vendor/vid dropdown
- /api/vendors: 162 real vendors + vids from vendor_catalog for the dropdown.
- /api/create-item: dryRun PREVIEW (default) → commit creates a Shopify DRAFT product (never
auto-published — go-live stays gated) + a dw_unified new_items_staging row (additive table, does
NOT touch canonical catalog); dedups on mfr#.
- '➕ New item' modal: photo(optional)+mfr+vendor dropdown(auto-fills vid)+name+color+price →
preview → 'Create draft'. Verified: 162 vendors, dryRun preview of Phillip Jeffries draft.
---
public/index.html | 49 +++++++++++++++++++++++++++++++++++++
server.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 121 insertions(+)
diff --git a/public/index.html b/public/index.html
index b6f532c..2ac2722 100644
--- a/public/index.html
+++ b/public/index.html
@@ -155,6 +155,9 @@
.fb-hit img{width:54px;height:54px;object-fit:cover;border-radius:8px;background:#000;flex-shrink:0}
.fb-conf{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;margin-left:6px}
.fb-conf.veryhigh,.fb-conf.high{background:#2b7a3f;color:#fff}.fb-conf.medium{background:#b8902f;color:#fff}.fb-conf.low{background:#7a3b2b;color:#fff}
+ .add-field{margin-bottom:10px;flex:1}.add-field label{display:block;font-size:11px;color:#9a917d;margin-bottom:3px}
+ .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}
.ed-panel{flex:0 0 auto;background:var(--card);border-top:1px solid var(--line);padding:12px 14px max(14px,env(safe-area-inset-bottom));max-height:54vh;overflow-y:auto}
.ed-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.ed-head .t{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.05em;text-transform:uppercase;color:var(--gold)}
@@ -210,6 +213,7 @@
<button class="scanbtn" id="camBtn" title="Choose camera (back / front)">📷 Back</button>
<button class="scanbtn" id="simBtn" title="Photograph a pattern/swatch → find this + similar across the whole catalog">🎨 Similar</button>
<button class="scanbtn" id="fbBtn" title="Identify by photos of the BACK label + FRONT pattern (best ID: code + visual)">🔎 ID F+B</button>
+ <button class="scanbtn" id="addBtn" title="Add a NEW item to the catalog + Shopify (draft)">➕ New item</button>
<input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
<input type="file" accept="image/*" capture="environment" id="simInput" hidden>
<input type="file" accept="image/*" capture="environment" id="fbBackInput" hidden>
@@ -353,6 +357,23 @@
<div id="fbResult"></div>
</div>
</div>
+
+<!-- add a NEW item to catalog + Shopify (draft), with vendor/vid dropdown -->
+<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>
+ <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="add-field"><label>Mfr # / SKU</label><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"></div>
+ <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>
+ <button class="samp-print" id="addPreview">👁 Preview</button>
+ <div class="samp-note" id="addNote"></div>
+ <div id="addResult"></div>
+ </div>
+</div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1369,6 +1390,34 @@ $('#fbFrontTile').addEventListener('click',()=>$('#fbFrontInput').click());
$('#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) ──
+let _addPhoto=null,_vendorsLoaded=false;
+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 }; }
+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();
+ if(r.duplicate){ $('#addNote').textContent='⚠ '+r.err; 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>`;
+ $('#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; }
+}
+$('#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){ _addPhoto=await downscale(f,1400); $('#addThumb').style.backgroundImage=`url('${_addPhoto}')`; $('#addThumb').textContent=''; } });
+$('#addPreview').addEventListener('click',addPreview);
$('#scanvideo').addEventListener('click',resumeScan); // tap the paused overlay to resume scanning
$('#scanStatus').addEventListener('click',resumeScan);
$('#scanCancel').addEventListener('click',closeLiveScan);
diff --git a/server.js b/server.js
index 46150c6..1d3a6d4 100644
--- a/server.js
+++ b/server.js
@@ -1097,6 +1097,26 @@ const appHandler = (req, res) => {
return;
}
+ // Vendor + vid list for the "add new item" dropdown (distinct real vendors from the catalog).
+ if (u.pathname === '/api/vendors' && req.method === 'GET') {
+ getVendors().then(list => send(res, 200, { ok: true, vendors: list })).catch(e => send(res, 200, { ok: false, err: e.message, vendors: [] }));
+ return;
+ }
+
+ // Add a NEW item → Shopify DRAFT + dw_unified staging. dryRun (default) PREVIEWS; commit:true writes.
+ // Never auto-published (going live is a separate gated step); dedups on mfr#.
+ if (u.pathname === '/api/create-item' && req.method === 'POST') {
+ let body = ''; req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });
+ 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;
+ try { send(res, 200, await createNewItem(p, b64, p.commit !== true)); }
+ catch (e) { send(res, 500, { ok: false, err: e.message }); }
+ });
+ 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') {
@@ -1559,6 +1579,58 @@ async function createFromSheet(s, b64) {
} catch (e) { return { ok: false, err: e.message }; }
}
+// Vendor + vid dropdown for the "add new item" form (distinct real vendors from the unified catalog).
+let _vendorsCache = { at: 0, list: [] };
+function getVendors() {
+ return new Promise(resolve => {
+ if (Date.now() - _vendorsCache.at < 10 * 60 * 1000 && _vendorsCache.list.length) return resolve(_vendorsCache.list);
+ const SQL = `select coalesce(nullif(original_vendor_name,''), vendor_code) vendor, vendor_code vid, count(*) n
+ from vendor_catalog where vendor_code is not null and vendor_code<>'' group by 1,2 having count(*)>5 order by n desc limit 300`;
+ execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL], { timeout: 8000, maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
+ if (err) return resolve(_vendorsCache.list);
+ const list = (out || '').split('\n').filter(Boolean).map(l => { const [vendor, vid, n] = l.split('\t'); return { vendor, vid, n: +n }; });
+ _vendorsCache = { at: Date.now(), list }; resolve(list);
+ });
+ });
+}
+
+// Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row. Draft-only,
+// never auto-published (going live is a separate gated step). dryRun (default) returns a preview.
+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' };
+ // dedup: refuse if the mfr# already exists in the catalog (attach instead, don't duplicate)
+ const dup = CATALOG.find(x => x.mfr && nmfr(x.mfr) === nmfr(mfr));
+ const dwsku = String(p.dw_sku || '').trim() || (String(p.vid || vendor).slice(0, 4).toUpperCase().replace(/[^A-Z0-9]/g, '') + mfr.replace(/[^A-Za-z0-9]/g, ''));
+ 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 };
+ 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 {
+ const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: 'single_line_text_field' } : null;
+ const variants = [{ option1: 'Roll', sku: dwsku, inventory_management: 'shopify', inventory_quantity: 0 },
+ { option1: 'Sample', sku: dwsku + '-Sample', price: '4.25', inventory_management: 'shopify', inventory_quantity: 0 }];
+ if (price) variants[0].price = price;
+ const payload = { product: {
+ 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)
+ } };
+ 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)}` };
+ const pid = cr.body && cr.body.product && cr.body.product.id;
+ // stage into dw_unified (additive table — does NOT touch canonical catalog rows)
+ const stageSQL = `insert into new_items_staging (dw_sku, mfr_sku, vendor, vid, pattern_name, color, price, shopify_product_id, created_via)
+ values ($$${dwsku}$$,$$${mfr}$$,$$${vendor}$$,$$${p.vid || ''}$$,$$${name}$$,$$${color}$$,${price || 'NULL'},${pid || 'NULL'},$$scan$$) on conflict do nothing`;
+ execFile(PSQL, ['-d', DW_DB, '-c', 'create table if not exists new_items_staging (id bigserial primary key, dw_sku text, mfr_sku text, vendor text, vid text, pattern_name text, color text, price numeric, shopify_product_id bigint, created_via text, created_at timestamptz default now()); ' + stageSQL], { timeout: 8000 }, () => {});
+ if (pid) CATALOG.push({ product_id: pid, title, status: 'DRAFT', dw_sku: dwsku, mfr, price, image: null, done: false });
+ return { ok: true, product_id: pid, dw_sku: dwsku, title, status: 'draft', preview };
+ } catch (e) { return { ok: false, err: e.message }; }
+}
+
// In-memory index of ALL non-archived Fentucci products (for "find any SKU" lookup).
let CATALOG = [], INDEX = [];
const nmfr = s => (s || '').trim().toUpperCase().replace(/T$/, ''); // normalize mfr (WOS3467T == wos3467)
← ac1d6d0 feat: front+back identify UI + QR/vendor clues. #fbModal cap
·
back to Dw Photo Capture
·
5x: app clean across 2 sweeps — M1/M2/B4 pass, 85 tests, bot 94d5ee9 →