← back to Dw Photo Capture
TK-12228: every captured photo, front and back, displays in the app
d1a0b78338a0680442100694bb2cb11f2e1e6a61 · 2026-09-25 09:38:47 -0700 · Steve Abrams
- index.html: front/back tiles show the captured photo immediately; Preview shows
both sides (and counts them, was "0 photo(s)"); Created + Update results show the
server-stored copies; tap to enlarge; links to the new Capture History.
- server.js: create-item/photo/photos persist every photo under photos/ with its side
in the filename (create-item now also on a failed create) and index one record per
item in data/captures.jsonl; responses return the stored URLs. New /captures page +
/api/captures merge that log with batch manifests and legacy vendor-log uploads.
- captures.html: history grid, front+back per card (missing side shown as a gap),
date+time chip, sort + density slider persisted in localStorage, lightbox.
- batch.html: FRONT (PSku) + BACK (Info) thumbs side by side, front shown on capture,
no more 8s blob revoke. cam.html: last remote shot shown on the phone.
- --test seam (DWP_TEST_DIR): rebased data/photos, Shopify/dw_unified/FM writes and
paid OCR stubbed; scripts/e2e-front-back-display.cjs drives it (19 checks).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
Files touched
M .gitignoreM public/batch.htmlM public/cam.htmlA public/captures.htmlM public/index.htmlA scripts/e2e-front-back-display.cjsM server.js
Diff
commit d1a0b78338a0680442100694bb2cb11f2e1e6a61
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 09:38:47 2026 -0700
TK-12228: every captured photo, front and back, displays in the app
- index.html: front/back tiles show the captured photo immediately; Preview shows
both sides (and counts them, was "0 photo(s)"); Created + Update results show the
server-stored copies; tap to enlarge; links to the new Capture History.
- server.js: create-item/photo/photos persist every photo under photos/ with its side
in the filename (create-item now also on a failed create) and index one record per
item in data/captures.jsonl; responses return the stored URLs. New /captures page +
/api/captures merge that log with batch manifests and legacy vendor-log uploads.
- captures.html: history grid, front+back per card (missing side shown as a gap),
date+time chip, sort + density slider persisted in localStorage, lightbox.
- batch.html: FRONT (PSku) + BACK (Info) thumbs side by side, front shown on capture,
no more 8s blob revoke. cam.html: last remote shot shown on the phone.
- --test seam (DWP_TEST_DIR): rebased data/photos, Shopify/dw_unified/FM writes and
paid OCR stubbed; scripts/e2e-front-back-display.cjs drives it (19 checks).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
---
.gitignore | 1 +
public/batch.html | 31 +++++-
public/cam.html | 14 +++
public/captures.html | 114 +++++++++++++++++++
public/index.html | 60 ++++++++--
scripts/e2e-front-back-display.cjs | 139 +++++++++++++++++++++++
server.js | 222 ++++++++++++++++++++++++++++++++-----
7 files changed, 544 insertions(+), 37 deletions(-)
diff --git a/.gitignore b/.gitignore
index 2a31434..7a25395 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ data/vendor-photos.backfilled
# agent git worktrees (TK-12205) — never track or deploy
.claude/worktrees/
+data/captures.jsonl
diff --git a/public/batch.html b/public/batch.html
index a1cb348..262762d 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -89,6 +89,14 @@
.shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--gold);background:var(--gold);cursor:pointer;flex:0 0 auto}
.shutter:disabled{background:#3a3428;border-color:#3a3428}
.thumb{width:56px;height:56px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f}
+ /* TK-12228: the last sample's FRONT (PSku) and BACK (Info) photos, side by side, tap to enlarge */
+ .thumbs{display:flex;gap:6px;flex:0 0 auto}
+ .thumbs figure{margin:0;position:relative;cursor:zoom-in}
+ .thumbs figure[hidden]{display:none}
+ .thumbs figcaption{position:absolute;left:3px;bottom:3px;font:800 9px/1 -apple-system,sans-serif;letter-spacing:.04em;background:#000b;color:var(--ink);padding:2px 4px;border-radius:4px}
+ #bLb{position:absolute;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+ #bLb[hidden]{display:none}
+ #bLb img{max-width:100%;max-height:80%;object-fit:contain;border-radius:8px}
.state{font:800 20px/1.2 "SF Pro Display",sans-serif;letter-spacing:.01em;min-height:26px;text-align:center;padding:0 10px}
.state.ready{color:var(--green)} .state.wait{color:var(--muted)} .state.retake{color:var(--red)}
/* ── big RETAKE / re-cal overlay ── */
@@ -241,7 +249,10 @@
<button class="btn ghost" id="detBtn" title="Show the technical details" style="padding:12px 14px">ⓘ</button>
<label class="chip" style="gap:6px;font-size:12px"><input type="checkbox" id="autoFire" style="width:auto"> Auto-snap</label>
<button class="shutter" id="shutter" title="Take photo"></button>
- <img class="thumb" id="thumb" alt="last photo" hidden>
+ <div class="thumbs">
+ <figure id="thumbFWrap" hidden><img class="thumb" id="thumb" alt="last FRONT photo"><figcaption>FRONT</figcaption></figure>
+ <figure id="thumbBWrap" hidden><img class="thumb" id="thumbB" alt="last BACK photo"><figcaption>BACK</figcaption></figure>
+ </div>
</div>
<div class="qmeta" id="queueTxt"></div>
</div>
@@ -252,6 +263,7 @@
<div class="big recal" id="bigRecal" hidden><h2>CHECK COLOR</h2><p>The light or stand moved, so colors won't match. Let's re-shoot the gray card — it only takes a second.</p><small id="recalMeta"></small><button class="btn primary" id="recalGo">Re-shoot gray card</button></div>
<div class="overlay" id="gate"><h2>Turn on the camera</h2><p>Tap the button, then put your first sample on the stand.</p><button class="btn primary" id="startCam">📷 Turn on camera</button></div>
<div class="toast" id="toast"></div>
+ <div id="bLb" hidden><b id="bLbCap" style="letter-spacing:.05em"></b><img id="bLbImg" alt="captured photo"><button class="btn ghost" id="bLbClose">✕ Close</button> <a href="/captures" style="color:var(--gold);font-size:13px">All captures →</a></div>
</div>
<script>
@@ -702,9 +714,22 @@ async function saveShot(photo, sku, m){
original:photo.original, master:photo.master, web:photo.web,
meta:{ via:m.via||'manual', at:new Date().toISOString(), skuConf, side:m.side||null, single:!!m.single, ...metaFields() } });
window._lastSku=sku;
- const url=URL.createObjectURL(photo.web); const th=$('#thumb'); th.src=url; th.hidden=false; setTimeout(()=>URL.revokeObjectURL(url),8000);
+ showThumb(m.side==='info'?'back':'front', photo.web);
drainQueue();
}
+// TK-12228: keep the last sample's FRONT (PSku) + BACK (Info) photos on screen. The object URL lives until
+// that slot is replaced (the old 8s revoke could blank a thumb before it was opened full-size).
+const _thumbUrl={front:null,back:null};
+function showThumb(side, blob){
+ const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'), img=$(side==='back'?'#thumbB':'#thumb'); if(!wrap||!img||!blob) return;
+ if(_thumbUrl[side]) URL.revokeObjectURL(_thumbUrl[side]);
+ _thumbUrl[side]=URL.createObjectURL(blob); img.src=_thumbUrl[side]; wrap.hidden=false;
+}
+function clearThumb(side){ const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'); if(wrap) wrap.hidden=true;
+ if(_thumbUrl[side]){ URL.revokeObjectURL(_thumbUrl[side]); _thumbUrl[side]=null; } const img=$(side==='back'?'#thumbB':'#thumb'); if(img) img.removeAttribute('src'); }
+['thumbFWrap','thumbBWrap'].forEach(id=>{ const w=document.getElementById(id); if(w) w.addEventListener('click',()=>{ const im=w.querySelector('img');
+ if(!im||!im.src) return; $('#bLbImg').src=im.src; $('#bLbCap').textContent=id==='thumbBWrap'?'BACK · label':'FRONT · pattern'; $('#bLb').hidden=false; }); });
+$('#bLbClose').addEventListener('click',()=>{ $('#bLb').hidden=true; });
// ── CAPTURE: PSku photo first; if the code is already on it → one entry, else request the Info photo ──
async function capture(via, fails){
if(busy) return;
@@ -723,10 +748,12 @@ async function capture(via, fails){
if(code){ // info-on-front → ONE entry, no second photo
fillFields(info.code?info:{code, confidence:skuConf});
session.n=(session.n||0)+1; saveJSON(LS_SESSION,session); $('#hN').textContent=session.n;
+ clearThumb('back'); // single-photo sample: no back to show
await saveShot(photo, code, { via, side:null, single:true, _seq:session.n });
toast('✓ '+code+' saved — all fields filled'); lastFireAt=Date.now(); resetForNextSample();
} else { // pattern-only front → hold it, request the Info photo
pendingFront=photo; phase='info'; setSideBadge();
+ showThumb('front', photo.web); clearThumb('back'); // TK-12228: the FRONT shows the moment it's taken
setState('wait','Flip the sample — put the LABEL in the box, then take the INFO photo');
toast('Now the Info photo (the label side)');
}
diff --git a/public/cam.html b/public/cam.html
index 199d9f2..a91d140 100644
--- a/public/cam.html
+++ b/public/cam.html
@@ -64,6 +64,13 @@
.target{font-size:13px;color:var(--muted);text-align:center;min-height:18px}
.target b{color:var(--gold);font:600 13px/1 ui-monospace,Menlo,monospace}
.hint{font-size:12px;color:var(--muted);text-align:center;max-width:340px}
+ /* TK-12228: the last shot, as stored on the server, shown on the phone too (tap to enlarge) */
+ .lastshot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
+ .lastshot[hidden]{display:none}
+ .lastshot img{width:64px;height:64px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f;cursor:zoom-in}
+ #camLb{position:fixed;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px}
+ #camLb[hidden]{display:none}
+ #camLb img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px}
.btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:15px 22px;font-weight:700;font-size:16px;min-height:54px;display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}
.btn.primary{background:var(--gold);color:#1b1407;border-color:var(--gold)}
.overlay{position:fixed;inset:0;z-index:50;background:#0f0e0cf2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;text-align:center}
@@ -107,7 +114,9 @@
<div class="foot">
<div class="target" id="target">Desktop not connected yet</div>
<div class="hint" id="hint">Keep this page open and your phone awake. The desktop will trigger the shot.</div>
+ <div class="lastshot" id="lastShot" hidden><img id="lastShotImg" alt="last shot"><span id="lastShotTxt"></span></div>
</div>
+ <div id="camLb" hidden><img id="camLbImg" alt="last shot full size"><button class="btn" id="camLbClose">✕ Close</button></div>
<!-- camera permission / wake gate -->
<div class="overlay" id="gate">
@@ -393,8 +402,13 @@ async function onShoot(target){
dw_sku: target.dw_sku||null, product_id: res&&res.product_id||target.product_id||null
}, res||{}))}).catch(()=>{});
toast(res&&res.ok ? (res.live?'🟢 LIVE · sent to desktop':'✓ Uploaded · sent to desktop') : ('Upload error'+(res&&res.err?': '+res.err:'')));
+ showLastShot(res&&res.ok&&res.photo ? res.photo : dataUrl, res&&res.ok ? (target.dw_sku||'saved') : 'not uploaded — local only');
busy=false;
}
+// TK-12228: show the shot on the phone — the server-stored copy when the upload worked, else the local capture.
+function showLastShot(src, label){ if(!src) return; $('#lastShotImg').src=src; $('#lastShotTxt').textContent='Last shot · '+label; $('#lastShot').hidden=false; }
+$('#lastShotImg').addEventListener('click',()=>{ $('#camLbImg').src=$('#lastShotImg').src; $('#camLb').hidden=false; });
+$('#camLbClose').addEventListener('click',()=>{ $('#camLb').hidden=true; });
// ── SSE + status ──
let es=null;
diff --git a/public/captures.html b/public/captures.html
new file mode 100644
index 0000000..adf1974
--- /dev/null
+++ b/public/captures.html
@@ -0,0 +1,114 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Capture History · DW</title>
+<!-- TK-12228 — Steve: "every photo, front and back, must show display on the app."
+ One card per captured item with its FRONT and BACK photos side by side (a missing side is shown
+ as a visible gap, never silently dropped). Sort + density slider persist in localStorage. -->
+<style>
+ :root{--bg:#0f0e0c;--card:#1b1916;--ink:#f3efe7;--muted:#9a9184;--line:#2e2a25;--gold:#c8a24a;--warn:#e6a23a;--cardw:260px}
+ *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.4 -apple-system,system-ui,sans-serif}
+ header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px}
+ h1{margin:0 0 10px;font-size:18px} h1 a{color:var(--muted);font-size:13px;text-decoration:none;margin-left:8px}
+ .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+ select,input[type=search]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:14px}
+ .dens{color:var(--muted);font-size:13px;display:flex;align-items:center;gap:6px}
+ .stat{color:var(--muted);font-size:13px} .stat b{color:var(--ink)} .stat .w{color:var(--warn)}
+ main{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardw),1fr));gap:12px;padding:14px 16px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
+ .card.miss{border-color:#6b551f}
+ .pair{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#000}
+ .pair figure{margin:0;position:relative;cursor:zoom-in}
+ .pair img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#000}
+ .pair figcaption{position:absolute;left:5px;bottom:5px;font:800 10px/1 -apple-system,sans-serif;letter-spacing:.05em;text-transform:uppercase;background:#000b;padding:3px 5px;border-radius:4px}
+ .pair .gap{cursor:default;aspect-ratio:1;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--warn);font-size:12px;padding:8px;background:#16120a}
+ .extras{display:flex;gap:2px;background:#000}
+ .extras figure{margin:0;flex:1;cursor:zoom-in} .extras img{width:100%;height:56px;object-fit:cover;display:block}
+ .meta{padding:8px 10px;font-size:12px;display:grid;gap:3px}
+ .sku{font-weight:600;font-size:13px}
+ .sub{color:var(--muted)}
+ .when{color:var(--gold)}
+ .bad{color:var(--warn)}
+ .empty{padding:40px 16px;color:var(--muted);text-align:center;grid-column:1/-1}
+ #lb{position:fixed;inset:0;z-index:50;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+ #lb[hidden]{display:none}
+ #lb img{max-width:100%;max-height:84vh;object-fit:contain;border-radius:8px}
+ #lb .cap{font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:14px}
+ #lb button{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:9px 16px;font-size:14px;cursor:pointer}
+</style></head>
+<body>
+<header>
+ <h1>Capture History <span id="count" style="color:var(--muted);font-weight:400;font-size:14px"></span><a href="/">← capture</a><a href="/vendor-photos">vendor photos</a></h1>
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search SKU, vendor, source">
+ <select id="sort" aria-label="Sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="missing">Missing a side first</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="vendor">Vendor A→Z</option>
+ <option value="source">Source</option>
+ </select>
+ <label class="dens">Size <input type="range" id="dens" min="160" max="520" step="20" value="260" aria-label="Card size"></label>
+ <span class="stat" id="stat"></span>
+ </div>
+</header>
+<main id="grid"></main>
+<div id="lb" hidden><div class="cap" id="lbCap"></div><img id="lbImg" alt="captured photo full size"><button id="lbClose">✕ Close</button></div>
+<script>
+const $ = s => document.querySelector(s);
+const ls = { get(k,d){ try{ return localStorage.getItem(k) ?? d; }catch(e){ return d; } }, set(k,v){ try{ localStorage.setItem(k,v); }catch(e){} } };
+const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const fmtDate = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const LBL = { front:'Front', back:'Back', extra:'Extra', photo:'Photo' };
+let all = [], sort = ls.get('cap.sort','newest');
+$('#sort').value = sort; $('#dens').value = ls.get('cap.dens','260');
+document.documentElement.style.setProperty('--cardw', $('#dens').value + 'px');
+const side = (c, s) => c.photos.find(p => p.side === s);
+const missing = c => !side(c,'front') || !side(c,'back');
+function fig(p, cls){ const lbl = LBL[p.side] || p.side;
+ return `<figure class="${cls||''}" data-src="${esc(p.url)}" data-cap="${esc(lbl)}"><img loading="lazy" src="${esc(p.url)}" alt="${esc(lbl)} photo" data-side="${esc(p.side)}">${cls==='x'?'':`<figcaption${p.inferred?' title="side inferred from the filename (captured before side-tagging)"':''}>${esc(lbl)}${p.inferred?'*':''}</figcaption>`}</figure>`; }
+function card(c){
+ // single-shot surfaces (remote cam / legacy single photo) have no front/back split — show what exists.
+ const f = side(c,'front'), b = side(c,'back');
+ const loose = c.photos.filter(p => p.side === 'photo');
+ let pair;
+ if (!f && !b && loose.length) pair = `<div class="pair" style="grid-template-columns:1fr">${fig(loose[0])}</div>`;
+ else pair = `<div class="pair">${f ? fig(f) : '<div class="gap">⚠ no FRONT photo</div>'}${b ? fig(b) : '<div class="gap">⚠ no BACK photo</div>'}</div>`;
+ const rest = c.photos.filter(p => p !== f && p !== b && !(p === loose[0] && !f && !b));
+ const extras = rest.length ? `<div class="extras">${rest.map(p => fig(p,'x')).join('')}</div>` : '';
+ const status = c.ok === false ? `<div class="bad">✗ create failed${c.err ? ': ' + esc(c.err) : ''}</div>` : '';
+ return `<div class="card${(f||b) && missing(c) ? ' miss' : ''}" data-id="${esc(c.id)}">${pair}${extras}
+ <div class="meta"><div class="sku">${esc(c.dw_sku || '—')}${c.title ? ' <span class="sub">· ' + esc(c.title) + '</span>' : ''}</div>
+ <div class="sub">${esc(c.vendor || 'no vendor')} · ${esc(c.source || '')} · ${c.photos.length} photo${c.photos.length === 1 ? '' : 's'}</div>
+ ${status}<div class="when" title="${esc(c.at)}">🕓 ${esc(fmtDate(c.at))}</div></div></div>`;
+}
+function render(){
+ const q = $('#q').value.trim().toLowerCase();
+ let rows = all.filter(c => !q || [c.dw_sku, c.vendor, c.source, c.title, c.mfr].some(v => String(v || '').toLowerCase().includes(q)));
+ const newest = (a,b) => String(b.at).localeCompare(String(a.at));
+ const by = { newest, oldest:(a,b)=>String(a.at).localeCompare(String(b.at)),
+ missing:(a,b)=>(missing(b)-missing(a)) || newest(a,b),
+ sku:(a,b)=>String(a.dw_sku||'').localeCompare(String(b.dw_sku||'')) || newest(a,b),
+ vendor:(a,b)=>String(a.vendor||'').localeCompare(String(b.vendor||'')) || newest(a,b),
+ source:(a,b)=>String(a.source||'').localeCompare(String(b.source||'')) || newest(a,b) };
+ rows.sort(by[sort] || newest);
+ $('#count').textContent = rows.length + ' of ' + all.length;
+ $('#grid').innerHTML = rows.length ? rows.map(card).join('') : '<div class="empty">No captures yet — shoot a front + back and it appears here.</div>';
+}
+$('#grid').addEventListener('click', e => { const f = e.target.closest('figure[data-src]'); if (!f) return;
+ $('#lbImg').src = f.dataset.src; $('#lbCap').textContent = f.dataset.cap || ''; $('#lb').hidden = false; });
+const closeLb = () => { $('#lb').hidden = true; $('#lbImg').removeAttribute('src'); };
+$('#lbClose').addEventListener('click', closeLb);
+$('#lb').addEventListener('click', e => { if (e.target.id === 'lb') closeLb(); });
+document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLb(); });
+$('#sort').addEventListener('change', e => { sort = e.target.value; ls.set('cap.sort', sort); render(); });
+$('#dens').addEventListener('input', e => { document.documentElement.style.setProperty('--cardw', e.target.value + 'px'); ls.set('cap.dens', e.target.value); });
+$('#q').addEventListener('input', render);
+fetch(location.origin + '/api/captures?limit=2000').then(r => r.json()).then(d => {
+ all = d.captures || [];
+ $('#stat').innerHTML = `<b>${d.total}</b> items` + (d.missing_back ? ` · <span class="w">${d.missing_back} without a back photo</span>` : '');
+ render();
+}).catch(() => { $('#grid').innerHTML = '<div class="empty">Could not load captures.</div>'; });
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index 4daaa60..963b2aa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -337,6 +337,23 @@
.shot-btn.need{border-color:#b8902f}
.shot-btn .shot-num{position:absolute;top:6px;left:9px;width:20px;height:20px;border-radius:50%;background:#2e2a25;color:#d8cba6;font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center}
.shot-btn.have .shot-num{background:#3f7d4f;color:#fff}
+ /* TK-12228: the captured photo is shown IN its tile the moment it lands (front AND back) */
+ .shot-btn .shot-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:12px;opacity:.9}
+ .shot-btn.have{overflow:hidden}
+ .shot-btn.have .shot-ico{display:none}
+ .shot-btn.have b,.shot-btn.have small,.shot-btn.have .shot-ico{position:relative;z-index:1;text-shadow:0 1px 3px #000,0 0 8px #000}
+ .shot-btn.have .shot-num,.shot-btn.have .shot-ck::after{z-index:2}
+ /* both-sides photo strip used by the Preview + Created + Update result views */
+ .ph-pair{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin:10px 0 4px}
+ .ph-pair figure{margin:0;background:#0f0e0b;border:1px solid var(--line);border-radius:10px;overflow:hidden;cursor:zoom-in}
+ .ph-pair img{display:block;width:100%;aspect-ratio:1;object-fit:cover;background:#000}
+ .ph-pair figcaption{font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:5px 8px;color:#c8ba97}
+ .ph-pair figure.miss{cursor:default;display:flex;align-items:center;justify-content:center;min-height:120px;color:#e6a23a;font-size:12px;text-align:center;padding:8px}
+ #photoLb{position:fixed;inset:0;z-index:9999;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
+ #photoLb[hidden]{display:none}
+ #photoLb img{max-width:100%;max-height:84vh;object-fit:contain;border-radius:8px}
+ #photoLb .lb-cap{color:#eadfc6;font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
+ #photoLb button{background:#16140f;color:#eadfc6;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:15px}
.shot-status{font-size:13px;margin:2px 0 8px;min-height:16px;color:#c8ba97}
.shot-status.block{color:#e6a23a}
/* OCR candidate picker — tap the correct detected SKU */
@@ -470,6 +487,7 @@
<a href="/simple-probooth">Pro Booth</a>
<a href="/simple-wizard">Guided Wizard</a>
<a href="/vendor-photos">Vendor Photos</a>
+ <a href="/captures">Capture History</a>
</nav>
</div>
</div>
@@ -521,6 +539,7 @@
<a href="/simple-probooth">Pro Booth</a>
<a href="/simple-wizard">Guided Wizard</a>
<a href="/vendor-photos">Vendor Photos</a>
+ <a href="/captures">Capture History</a>
</nav>
<style>
.other-apps{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;
@@ -671,8 +690,8 @@
<!-- 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-num">1</span><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-num">2</span><span class="shot-ico">🏷</span><b>Back</b><small>label / SKU</small><i class="shot-ck" id="backCk"></i></button>
+ <button class="shot-btn" id="frontBtn"><img class="shot-img" id="frontImg" alt="Front photo" hidden><span class="shot-num">1</span><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"><img class="shot-img" id="backImg" alt="Back photo" hidden><span class="shot-num">2</span><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>
<!-- OCR fallback: when the mfr# can't be auto-determined, offer detected code candidates to tap -->
@@ -706,6 +725,9 @@
</div>
</div>
+<!-- TK-12228: tap any captured photo (front/back) to see it full-size -->
+<div id="photoLb" hidden><div class="lb-cap" id="photoLbCap"></div><img id="photoLbImg" alt="captured photo"><button id="photoLbClose">✕ Close</button></div>
+
<!-- Measure Depth: a credit card (3.375") in frame sets the px→inch scale, then size the box to the goods -->
<div id="measureModal" hidden>
<video id="mVideo" autoplay playsinline muted webkit-playsinline></video>
@@ -2117,7 +2139,25 @@ function twoPhotoGate(){ const mfr=curMfr();
}
function renderShotTiles(){
$('#frontBtn').classList.toggle('have',!!_frontPhoto); $('#frontBtn').classList.toggle('need',!_frontPhoto);
- $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_backPhoto); }
+ $('#backBtn').classList.toggle('have',!!_backPhoto); $('#backBtn').classList.toggle('need',!!_frontPhoto && !_backPhoto);
+ // TK-12228: show the actual captured photo inside its tile (immediate preview of FRONT and BACK)
+ [['#frontImg',_frontPhoto],['#backImg',_backPhoto]].forEach(([id,src])=>{ const im=$(id); if(!im) return;
+ if(src){ if(im.getAttribute('src')!==src) im.src=src; im.hidden=false; } else { im.removeAttribute('src'); im.hidden=true; } }); }
+// ── TK-12228: both-sides photo strip for the Preview / Created / Update result views + tap-to-enlarge ──
+const SIDE_LBL={front:'Front · pattern',back:'Back · label',extra:'Extra',photo:'Photo'};
+function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
+// list: [{side,url}] — FRONT and BACK always get a slot; a missing side is shown as a visible gap, never silently dropped.
+function photoPairHtml(list,opts){ opts=opts||{}; list=(list||[]).filter(x=>x&&x.url);
+ const has=s=>list.some(x=>x.side===s);
+ const cells=list.map(x=>`<figure data-src="${escAttr(x.url)}" data-cap="${escAttr(SIDE_LBL[x.side]||x.side)}"><img src="${escAttr(x.url)}" alt="${escAttr(SIDE_LBL[x.side]||x.side)} photo" data-side="${escAttr(x.side)}"><figcaption>${SIDE_LBL[x.side]||x.side}</figcaption></figure>`);
+ if(opts.requireTwo){ if(!has('front')) cells.unshift('<figure class="miss">⚠ no FRONT photo</figure>'); if(!has('back')) cells.splice(1,0,'<figure class="miss">⚠ no BACK photo</figure>'); }
+ return `<div class="ph-pair" data-set="${escAttr(opts.set||'')}">${cells.join('')}</div>`; }
+function localPhotoList(){ return [{side:'front',url:_frontPhoto},{side:'back',url:_backPhoto}].filter(x=>x.url)
+ .concat(_media.filter(m=>m.type==='photo').map(m=>({side:'extra',url:m.dataUrl}))); }
+function openPhotoLb(src,cap){ $('#photoLbImg').src=src; $('#photoLbCap').textContent=cap||''; $('#photoLb').hidden=false; }
+document.addEventListener('click',ev=>{ const f=ev.target.closest&&ev.target.closest('.ph-pair figure[data-src]'); if(!f) return; ev.preventDefault(); openPhotoLb(f.dataset.src,f.dataset.cap); });
+$('#photoLbClose').addEventListener('click',()=>{ $('#photoLb').hidden=true; $('#photoLbImg').removeAttribute('src'); });
+$('#photoLb').addEventListener('click',ev=>{ if(ev.target.id==='photoLb'){ $('#photoLb').hidden=true; $('#photoLbImg').removeAttribute('src'); } });
function renderShots(){ renderShotTiles();
if(_autoBusy) return; // mid-extract: autoExtract owns the status line
if($('#candPick') && $('#candPick').innerHTML) return; // candidate picker owns the status line
@@ -2589,12 +2629,13 @@ async function addPreview(){ if(_addMode==='update') return addUpdate();
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; }
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); return; }
- const pv=r.preview, c=mediaCounts(); $('#addNote').textContent='Preview — nothing created yet (all 3 systems).';
+ const pv=r.preview, c=mediaCounts(), nAll=addPhotosList().length; $('#addNote').textContent='Preview — nothing created yet (all 3 systems).';
const fm=pv.filemaker||{}, esc=s=>String(s==null?'':s).replace(/</g,'<');
const fmRows=Object.entries(fm.fieldData||{}).map(([k,v])=>`<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('');
const flagsHtml=(pv.flags&&pv.flags.length)?`<div class="pv-flags">⚠ ${pv.flags.map(esc).join('<br>⚠ ')}</div>`:'';
$('#addResult').innerHTML=`
- <div class="fb-hit"><div><b>${esc(pv.title)}</b><br><small><b>DW# ${esc(pv.dw_sku)}</b>${pv.provisional_sku?' <span style="color:#e0a53a">(PROVISIONAL)</span>':''} · ${c.p} photo(s) · ${c.v} video(s)</small></div></div>
+ <div class="fb-hit"><div><b>${esc(pv.title)}</b><br><small><b>DW# ${esc(pv.dw_sku)}</b>${pv.provisional_sku?' <span style="color:#e0a53a">(PROVISIONAL)</span>':''} · ${nAll} photo(s) · ${c.v} video(s)</small></div></div>
+ ${photoPairHtml(localPhotoList(),{requireTwo:true,set:'preview'})}
${flagsHtml}
<div class="pv-sys"><div class="pv-h">🛍 Shopify draft</div><small>${esc(pv.shopify.vendor)} · ${esc(pv.shopify.product_type)} · <b>DRAFT</b><br>Roll <b>${esc(pv.shopify.variants[0].sku)}</b> @ ${esc(pv.shopify.variants[0].price)} · Sample <b>${esc(pv.shopify.variants[1].sku)}</b> @ $4.25</small></div>
<div class="pv-sys"><div class="pv-h">🗄 dw_unified · new_items_staging</div><small>dw_sku <b>${esc(pv.staging.dw_sku)}</b> · vid ${esc(pv.staging.vid||'—')} · ${esc(pv.staging.pattern_name||'—')} / ${esc(pv.staging.color||'—')}</small></div>
@@ -2614,7 +2655,9 @@ async function addCommit(){ const g=twoPhotoGate(); if(!g.ok){ $('#addNote').tex
const baseMsg=`✅ Created ${r.dw_sku} — ${nPh>=2?'front+back':nPh+' photo(s)'} saved${vmsg}`;
$('#addNote').innerHTML=`${baseMsg} <small>— DRAFT, not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
// CONTINUE-ABLE: a prominent Next button clears the item but keeps vendor + calibration + white-balance
- $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${r.dw_sku}</b> <small>created (draft) · ${_sessCount} this session</small></div></div><button class="samp-print" id="addNext" style="margin-top:12px">➕ Next item (keep vendor)</button>`;
+ // TK-12228: show the photos AS STORED on the server (front + back) — proves they persisted + are served back.
+ const saved=(r.captured&&r.captured.photos&&r.captured.photos.length)?r.captured.photos:localPhotoList();
+ $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${r.dw_sku}</b> <small>created (draft) · ${_sessCount} this session</small></div></div>${photoPairHtml(saved,{requireTwo:true,set:'created'})}<div class="samp-meta" style="margin-top:6px"><a href="/captures" style="color:var(--gold)">View all captures →</a></div><button class="samp-print" id="addNext" style="margin-top:12px">➕ Next item (keep vendor)</button>`;
$('#addNext').addEventListener('click',nextItem);
if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
}
@@ -2677,7 +2720,10 @@ async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
$('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; 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; }
+ let savedPh=[];
+ if(photos.length){ const sides=localPhotoList().map(x=>x.side);
+ 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,sides})})).json(); added=r.added||0; savedPh=r.photos||[]; }
+ $('#addResult').innerHTML=savedPh.length?photoPairHtml(savedPh,{set:'updated'}):''; // TK-12228: show what was stored
const hadVideos=addVideosList().length;
const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
diff --git a/scripts/e2e-front-back-display.cjs b/scripts/e2e-front-back-display.cjs
new file mode 100644
index 0000000..1a6c0fb
--- /dev/null
+++ b/scripts/e2e-front-back-display.cjs
@@ -0,0 +1,139 @@
+// Usage (TK-12228): start a test server, then run this against it:
+// mkdir -p /tmp/dwp-e2e && PORT=9987 DWP_TEST_DIR=/tmp/dwp-e2e node server.js --test &
+// TESTROOT=/tmp/dwp-e2e OUT=/tmp/dwp-e2e/shots BASE=http://127.0.0.1:9987 node scripts/e2e-front-back-display.cjs
+// (PW=<path to a playwright package> if not the default; exit 0 = all pass, 1 = a check failed, 2 = crash)
+// TK-12228 E2E: every captured photo (front + back) displays — preview, result, history, batch, remote cam.
+// Drives the REAL app (server started with --test: Shopify/dw_unified/FM writes + paid OCR stubbed).
+const { chromium } = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
+const fs = require('fs'), path = require('path');
+const BASE = process.env.BASE || 'http://127.0.0.1:9987';
+const OUT = process.env.OUT || path.join(process.env.TESTROOT || '.', 'shots');
+const TESTROOT = process.env.TESTROOT; if (!TESTROOT) { console.error('TESTROOT (= the server DWP_TEST_DIR) required'); process.exit(2); }
+fs.mkdirSync(OUT, { recursive: true });
+const AUTH = { username: 'admin', password: 'DW2024!' };
+const results = []; let failed = 0;
+function check(name, cond, detail) { results.push({ name, ok: !!cond, detail }); if (!cond) failed++; console.log((cond ? 'PASS ' : 'FAIL ') + name + (detail ? ' — ' + JSON.stringify(detail) : '')); }
+// every <img> matched must be fully loaded with real pixels
+async function imgs(page, sel) {
+ await page.waitForFunction(s => { const l = [...document.querySelectorAll(s)]; return l.length && l.every(i => i.complete); }, sel, { timeout: 15000 }).catch(() => {});
+ return page.$$eval(sel, l => l.map(i => ({ side: i.dataset.side || i.alt, w: i.naturalWidth, h: i.naturalHeight, src: (i.currentSrc || i.src).slice(0, 80) })));
+}
+const allLoaded = l => l.length > 0 && l.every(x => x.w > 0 && x.h > 0);
+
+(async () => {
+ const browser = await chromium.launch({ headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] });
+ const ctx = await browser.newContext({ httpCredentials: AUTH, permissions: ['camera', 'microphone'], viewport: { width: 430, height: 932 } });
+ const page = await ctx.newPage();
+ page.on('pageerror', e => console.log('[pageerror]', e.message));
+
+ // ── 1. index.html ADD flow: live two-shot camera → FRONT then BACK ──
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.click('#homeScreen [data-act="add"]');
+ await page.waitForSelector('#twoShotCam:not([hidden])', { timeout: 15000 });
+ await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 20000 });
+ await page.waitForTimeout(600);
+ await page.click('#tsShutterBtn'); // FRONT
+ await page.waitForFunction(() => document.querySelector('#tsStepName').textContent === 'BACK', null, { timeout: 15000 });
+ await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 15000 });
+ const tsThumb = await imgs(page, '#tsThumb');
+ check('live cam: FRONT thumbnail shows while shooting BACK', allLoaded(tsThumb), tsThumb);
+ await page.screenshot({ path: path.join(OUT, '01-livecam-front-thumb.png') });
+ await page.click('#tsShutterBtn'); // BACK
+ await page.waitForSelector('#twoShotCam', { state: 'hidden', timeout: 15000 });
+ await page.waitForSelector('#frontImg:not([hidden])'); await page.waitForSelector('#backImg:not([hidden])');
+ const tiles = await imgs(page, '#frontImg, #backImg');
+ check('immediate preview: FRONT + BACK tiles both render', tiles.length === 2 && allLoaded(tiles), tiles);
+ await page.locator('.two-shot').screenshot({ path: path.join(OUT, '02-tiles-front-back.png') });
+
+ // vendor + mfr (OCR is stubbed → manual entry path)
+ await page.waitForFunction(() => document.querySelectorAll('#addVendor option').length > 1, null, { timeout: 10000 });
+ await page.selectOption('#addVendor', 'Test Vendor');
+ await page.fill('#addMfr', 'TK12228A');
+ await page.click('#addPreview');
+ await page.waitForSelector('.ph-pair[data-set="preview"] img', { timeout: 15000 });
+ const pv = await imgs(page, '.ph-pair[data-set="preview"] img');
+ check('Preview view: FRONT + BACK render', pv.map(x => x.side).join(',') === 'front,back' && allLoaded(pv), pv);
+ const pvCount = await page.$eval('#addResult .fb-hit small', e => e.textContent);
+ check('Preview counts the front+back photos (was "0 photo(s)")', /2 photo\(s\)/.test(pvCount), pvCount);
+ await page.locator('#addResult').screenshot({ path: path.join(OUT, '03-preview-front-back.png') });
+
+ await page.click('#addCommit');
+ await page.waitForSelector('.ph-pair[data-set="created"] img', { timeout: 20000 });
+ const cr = await imgs(page, '.ph-pair[data-set="created"] img');
+ check('Created result: FRONT + BACK render from server /photos/ URLs', cr.map(x => x.side).join(',') === 'front,back' && allLoaded(cr) && cr.every(x => x.src.startsWith(BASE + '/photos/')), cr);
+ await page.locator('#addResult').screenshot({ path: path.join(OUT, '04-created-front-back.png') });
+ await page.click('.ph-pair[data-set="created"] figure:nth-child(2)');
+ await page.waitForSelector('#photoLb:not([hidden])');
+ const lb = await imgs(page, '#photoLbImg');
+ check('Created result: tap BACK → full-size lightbox', allLoaded(lb) && /back/i.test(await page.$eval('#photoLbCap', e => e.textContent)), lb);
+ await page.screenshot({ path: path.join(OUT, '05-lightbox-back.png') });
+ await page.click('#photoLbClose');
+
+ // files really persisted on disk
+ const disk = fs.readdirSync(path.join(TESTROOT, 'photos')).filter(f => /^TEST-TK12228A-(front|back)-/.test(f));
+ check('both photos persisted under photos/ with side in filename', disk.some(f => /-front-/.test(f)) && disk.some(f => /-back-/.test(f)), disk);
+
+ // ── 2. Update mode (/api/photos) + batch (/api/batch-shot) + legacy vendor-log via API, then history ──
+ const jpgFront = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#3a7'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('FRONT', 90, 130); return c.toDataURL('image/jpeg', 0.9); });
+ const jpgBack = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#a53'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('BACK', 100, 130); return c.toDataURL('image/jpeg', 0.9); });
+ const post = (u, b) => page.evaluate(async ([u, b]) => (await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) })).json(), [u, b]);
+ const up = await post('/api/photos', { dw_sku: 'UPD-777', product_id: 123, dataUrls: [jpgFront, jpgBack], sides: ['front', 'back'] });
+ check('/api/photos returns both stored photos with sides', up.photos && up.photos.map(p => p.side).join(',') === 'front,back', up);
+ const b1 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgFront, meta: { side: 'psku' } });
+ const b2 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgBack, meta: { side: 'info' } });
+ check('/api/batch-shot accepted psku + info', b1.ok && b2.ok, { b1: b1.paths, b2: b2.paths });
+ // legacy: a pre-TK-12228 create-item (vendor-log only, -0/-1 tags) must still show front + back in history
+ const vdir = path.join(TESTROOT, 'photos', 'vendors', 'Legacy Co'); fs.mkdirSync(vdir, { recursive: true });
+ const t0 = Date.now() - 86400000;
+ [['0', jpgFront], ['1', jpgBack]].forEach(([tag, d]) => fs.writeFileSync(path.join(vdir, `LEG-5-${t0}-${tag}.jpg`), Buffer.from(d.split(',')[1], 'base64')));
+ fs.appendFileSync(path.join(TESTROOT, 'data', 'vendor-photos.jsonl'), [0, 1].map(tag => JSON.stringify({ at: new Date(t0 + tag).toISOString(), vendor: 'Legacy Co', dw_sku: 'LEG-5', source: 'create-item', path: `/photos/vendors/Legacy%20Co/LEG-5-${t0}-${tag}.jpg` })).join('\n') + '\n');
+
+ // ── 3. /captures history grid ──
+ await page.goto(BASE + '/captures', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.card');
+ const cards = await page.$$eval('.card', l => l.map(c => ({ sku: c.querySelector('.sku').textContent.split(' ')[0], sides: [...c.querySelectorAll('.pair img')].map(i => i.dataset.side), when: c.querySelector('.when').textContent, title: c.querySelector('.when').title })));
+ const want = ['TEST-TK12228A', 'UPD-777', 'BAT-1', 'LEG-5'];
+ for (const sku of want) { const c = cards.find(x => x.sku === sku); check(`history card ${sku} shows front+back`, c && c.sides.join(',') === 'front,back', c); }
+ const hist = await imgs(page, '.card .pair img');
+ check('history: every front/back thumbnail has pixels (naturalWidth>0)', hist.length >= 8 && allLoaded(hist), { n: hist.length, bad: hist.filter(x => !x.w) });
+ const c0 = cards[0];
+ check('history card shows created date+time chip with ISO title', /\d{4}/.test(c0.when) && /\d:\d\d/.test(c0.when) && /T.*Z$/.test(c0.title), c0);
+ await page.screenshot({ path: path.join(OUT, '06-history-grid.png'), fullPage: true });
+ // sort + density persist across reload
+ await page.selectOption('#sort', 'sku'); await page.fill('#dens', '200'); await page.dispatchEvent('#dens', 'input');
+ await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.card');
+ const persisted = await page.evaluate(() => ({ sort: document.querySelector('#sort').value, dens: document.querySelector('#dens').value, first: document.querySelector('.card .sku').textContent }));
+ check('history sort + density persist in localStorage', persisted.sort === 'sku' && persisted.dens === '200', persisted);
+ await page.click('.card .pair figure[data-side], .card .pair figure');
+ await page.waitForSelector('#lb:not([hidden])');
+ check('history: click thumbnail → enlarge', allLoaded(await imgs(page, '#lbImg')));
+ await page.screenshot({ path: path.join(OUT, '07-history-lightbox.png') });
+
+ // ── 4. batch.html thumbs (unit-level: drive the page's own showThumb with real JPEG blobs) ──
+ await page.goto(BASE + '/batch', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(async ([f, b]) => {
+ const blob = async d => (await fetch(d)).blob();
+ document.getElementById('vShoot').hidden = false;
+ showThumb('front', await blob(f)); showThumb('back', await blob(b));
+ }, [jpgFront, jpgBack]);
+ const bt = await imgs(page, '#thumb, #thumbB');
+ check('batch.html: last sample FRONT + BACK thumbs both render', bt.length === 2 && allLoaded(bt), bt);
+ await page.locator('.thumbs').screenshot({ path: path.join(OUT, '08-batch-thumbs.png') });
+
+ // ── 5. cam.html remote shutter: shot shows on the phone from the server-stored copy ──
+ const cam = await ctx.newPage();
+ cam.on('pageerror', e => console.log('[cam pageerror]', e.message));
+ await cam.goto(BASE + '/cam', { waitUntil: 'domcontentloaded' });
+ await cam.click('#gBtn').catch(() => {});
+ for (let i = 0; i < 40; i++) { const st = await cam.evaluate(async () => (await fetch('/api/pair/status')).json()); if (st.cam_connected && st.cam_live) break; await cam.waitForTimeout(500); }
+ const sh = await post('/api/pair/shoot', {});
+ await cam.waitForSelector('#lastShot:not([hidden])', { timeout: 20000 });
+ const cl = await imgs(cam, '#lastShotImg');
+ check('cam.html: remote shot displays on the phone (server /photos/ copy)', sh.ok && allLoaded(cl) && cl[0].src.includes('/photos/'), { sh, cl });
+ await cam.screenshot({ path: path.join(OUT, '09-cam-lastshot.png') });
+
+ fs.writeFileSync(path.join(OUT, 'results.json'), JSON.stringify({ failed, results }, null, 2));
+ console.log(`\n${results.length - failed}/${results.length} passed`);
+ await browser.close();
+ process.exit(failed ? 1 : 0);
+})().catch(e => { console.error('E2E CRASH', e); process.exit(2); });
diff --git a/server.js b/server.js
index 7555e10..cd6bfb6 100644
--- a/server.js
+++ b/server.js
@@ -30,6 +30,16 @@ process.on('uncaughtException', (err) => {
try { console.error('[uncaughtException]', (err && err.stack) || err); } catch (e) {}
});
+// ── Test seam (TK-12228) ─────────────────────────────────────────────────────
+// `node server.js --test` (DWP_TEST_DIR required) rebases data/ + photos/ into a scratch dir and
+// STUBS every Shopify write (shopifyReq) + the whole createNewItem commit (Shopify/dw_unified/FM),
+// and skips the catalog/FileMaker boot fetches. Lets a headless browser exercise the real capture
+// → persist → display path end to end with zero external writes. pm2/launchd NEVER pass --test.
+const TEST_MODE = process.argv.includes('--test');
+const TEST_DIR = TEST_MODE ? String(process.env.DWP_TEST_DIR || '') : '';
+if (TEST_MODE && !TEST_DIR) { console.error('--test requires DWP_TEST_DIR (scratch dir for data/ + photos/)'); process.exit(2); }
+if (TEST_MODE) console.log('[TEST MODE] data+photos under', TEST_DIR, '— Shopify/dw_unified/FileMaker writes STUBBED');
+
// FileMaker Cloud client (sample-request lookup + sticker-print flag). Loaded guarded so a missing
// dep / unset creds degrade gracefully instead of crashing the scanner.
let FM = null;
@@ -53,7 +63,7 @@ const VSEARCH_URL = process.env.VSEARCH_URL || 'http://127.0.0.1:9914'; // CLI
// for a "samples coming in" queue. Refreshed at boot + every 8 min + on a stale read.
let _incomingCache = { at: 0, samples: [] }, _incomingLoading = false;
async function refreshIncoming() {
- if (_incomingLoading || !FM_ENABLED()) return;
+ if (_incomingLoading || !FM_ENABLED() || TEST_MODE) return;
_incomingLoading = true;
try {
const floor = new Date(Date.now() - 120 * 864e5).toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' });
@@ -89,8 +99,8 @@ setTimeout(refreshIncoming, 9000); // warm the cache shortly aft
setInterval(refreshIncoming, 8 * 60 * 1000); // keep it fresh
const ROOT = __dirname;
-const DATA = path.join(ROOT, 'data');
-const PHOTOS = path.join(ROOT, 'photos');
+const DATA = TEST_MODE ? path.join(TEST_DIR, 'data') : path.join(ROOT, 'data');
+const PHOTOS = TEST_MODE ? path.join(TEST_DIR, 'photos') : path.join(ROOT, 'photos');
const QUEUE_FILE = path.join(DATA, 'queue.json');
const PROGRESS_FILE = path.join(DATA, 'progress.json');
const PORT = process.env.PORT || 9890;
@@ -115,6 +125,7 @@ try {
}
} catch (e) {}
if (!TOKEN) TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
+if (TEST_MODE) TOKEN = 'TEST-STUB'; // never a real token in test mode — shopifyReq below is stubbed
fs.mkdirSync(PHOTOS, { recursive: true });
@@ -175,15 +186,103 @@ function archiveVendorPhoto(srcFile, { vendor, dw_sku, source, at, name } = {})
return rec;
} catch (e) { console.error('[vendor-photos] archive failed', srcFile, e.message); return null; }
}
-// Write a base64 JPEG straight into the vendor archive (for photos that were never saved locally,
-// e.g. the create-item photos that only went to Shopify).
-function archiveVendorB64(b64, { vendor, dw_sku, source, tag }) {
+// (archiveVendorB64 retired TK-12228: create-item photos now go through savePhotoB64 → archiveVendorPhoto
+// so each file carries its front/back side and is indexed in data/captures.jsonl.)
+// ── Capture history (TK-12228 — Steve: "every photo, front and back, must show display on the app") ──
+// Every capture that reaches the server is persisted under photos/ with its SIDE in the filename and
+// indexed in data/captures.jsonl as ONE record per item (front + back + extras together), so the
+// result view and the /captures history grid can show BOTH sides. Batch-mode shots are read from
+// their existing per-session manifests; captures from before this log existed are reconstructed from
+// the vendor-photo archive log (sides inferred from the -0/-1 filename tag).
+const CAPTURE_LOG = path.join(DATA, 'captures.jsonl');
+const SIDES = ['front', 'back', 'extra', 'photo'];
+// sides[] for an ordered upload list [front?, back?, ...extras] given which of front/back are present.
+function sidesFor(n, frontPresent, backPresent) {
+ const out = []; let i = 0;
+ if (frontPresent !== false && i < n) out[i++] = 'front';
+ if (backPresent && i < n) out[i++] = 'back';
+ while (i < n) out[i++] = 'extra';
+ return out;
+}
+const cleanSide = s => (SIDES.includes(s) ? s : 'photo');
+// Write one base64 JPEG under photos/ as <SKU>-<side>-<ts>-<i>.jpg → its /photos/ URL (null on failure).
+function savePhotoB64(b64, dw_sku, side, i) {
try {
- const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '');
- const f = path.join(PHOTOS, `${safe}-${Date.now()}-${tag || 0}.jpg`);
- fs.writeFileSync(f, Buffer.from(b64, 'base64'));
- return archiveVendorPhoto(f, { vendor, dw_sku, source });
- } catch (e) { console.error('[vendor-photos] b64 archive failed', e.message); return null; }
+ const buf = Buffer.from(String(b64 || ''), 'base64');
+ if (buf.length < 200) return null;
+ const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 80) || 'nosku';
+ const fname = `${safe}-${cleanSide(side)}-${Date.now()}-${i || 0}.jpg`;
+ fs.writeFileSync(path.join(PHOTOS, fname), buf);
+ return { file: path.join(PHOTOS, fname), url: `/photos/${fname}` };
+ } catch (e) { console.error('[captures] save failed', e.message); return null; }
+}
+function recordCapture(rec) {
+ try {
+ const at = new Date().toISOString();
+ const full = Object.assign({ id: at.replace(/[:.]/g, '-') + '-' + Math.random().toString(36).slice(2, 7), at }, rec);
+ full.photos = (full.photos || []).filter(p => p && p.url).map(p => ({ side: cleanSide(p.side), url: p.url }));
+ fs.appendFileSync(CAPTURE_LOG, JSON.stringify(full) + '\n');
+ return full;
+ } catch (e) { console.error('[captures] log failed', e.message); return null; }
+}
+const readJsonl = f => { const out = []; try { for (const l of fs.readFileSync(f, 'utf8').split('\n')) { if (!l) continue; try { out.push(JSON.parse(l)); } catch (e) {} } } catch (e) {} return out; };
+// Merge the three sources into one newest-first list of { id, at, source, dw_sku, vendor, photos[] }.
+function listCaptures() {
+ const items = [], seen = new Set();
+ const base = u => { try { return decodeURIComponent(String(u).split('/').pop()); } catch (e) { return String(u).split('/').pop(); } };
+ for (const r of readJsonl(CAPTURE_LOG)) {
+ if (!r || !Array.isArray(r.photos)) continue;
+ r.photos.forEach(p => seen.add(base(p.url)));
+ items.push(Object.assign({ source: 'capture' }, r));
+ }
+ // batch manifests: one record per (session, sku, seq); psku/front → front, info/back → back
+ const mdir = path.join(DATA, 'batch-sessions');
+ try {
+ for (const m of fs.readdirSync(mdir)) {
+ if (!/\.jsonl$/.test(m)) continue;
+ const groups = new Map();
+ for (const r of readJsonl(path.join(mdir, m))) {
+ const key = `${r.sessionId}|${r.sku}|${r.seq == null ? '' : r.seq}`;
+ let g = groups.get(key);
+ if (!g) { g = { id: 'batch:' + key, at: r.at, source: 'batch', session: r.sessionId, dw_sku: r.sku, vendor: r.vendor || null, bySide: {} }; groups.set(key, g); }
+ if (r.at && r.at < g.at) g.at = r.at;
+ const ms = r.meta && r.meta.side;
+ const side = ms === 'info' || ms === 'back' ? 'back' : 'front'; // psku/front/unsided single → front
+ const pth = r.paths || {};
+ const url = pth.web || pth.master || pth.original;
+ if (url) g.bySide[side] = url;
+ }
+ for (const g of groups.values()) {
+ g.photos = ['front', 'back'].filter(s => g.bySide[s]).map(s => ({ side: s, url: g.bySide[s] }));
+ delete g.bySide; if (g.photos.length) items.push(g);
+ }
+ }
+ } catch (e) { /* no batch sessions yet */ }
+ // legacy uploads (before captures.jsonl): the vendor-photo archive log, grouped per SKU+source+~2min
+ const legacy = [];
+ const vseen = new Set();
+ for (const r of readJsonl(VENDOR_LOG)) {
+ if (!r || !r.path || vseen.has(r.path)) continue; vseen.add(r.path);
+ if (/batch/.test(r.source || '')) continue; // batch shots come from their manifests
+ const b = base(r.path);
+ if (seen.has(b) || /^(scan-|_ocr-)/.test(b)) continue;
+ legacy.push(r);
+ }
+ legacy.sort((a, b) => String(a.at).localeCompare(String(b.at)));
+ let cur = null;
+ for (const r of legacy) {
+ const t = Date.parse(r.at) || 0;
+ if (!cur || cur.dw_sku !== (r.dw_sku || null) || cur._src !== r.source || t - cur._t > 120000) {
+ cur = { id: 'legacy:' + r.path, at: r.at, source: 'legacy-' + (r.source || 'upload'), _src: r.source, _t: t, dw_sku: r.dw_sku || null, vendor: r.vendor || null, photos: [] };
+ items.push(cur);
+ }
+ const tag = /-\d{10,}-(\d+)\.jpe?g$/i.exec(base(r.path));
+ const side = tag ? (tag[1] === '0' ? 'front' : tag[1] === '1' ? 'back' : 'extra') : 'photo';
+ cur.photos.push({ side, url: r.path, inferred: true });
+ }
+ for (const it of items) { delete it._src; delete it._t; }
+ items.sort((a, b) => String(b.at).localeCompare(String(a.at)));
+ return items;
}
// One-time backfill of photos saved before the archive existed (flat photos/<SKU>-*.jpg and
// batch/<session>/). Runs once the catalog is loaded; marker file makes it idempotent.
@@ -265,6 +364,10 @@ function cardRecord(dw_sku, meta, extra) {
function shopifyReq(method, path, payload) {
return new Promise((resolve) => {
+ if (TEST_MODE) { // TK-12228 test seam: no network. Image POSTs "succeed" so attach paths run; all else is inert.
+ if (method === 'POST' && /\/images\.json$/.test(path)) return resolve({ status: 201, body: { image: { id: Date.now() } }, raw: '' });
+ return resolve({ status: 0, body: null, err: 'test-stub' });
+ }
if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
const data = payload ? JSON.stringify(payload) : null;
const req = https.request({
@@ -1065,6 +1168,26 @@ const appHandler = (req, res) => {
if (u.pathname === '/healthz') return send(res, 200, { ok: true });
+ // TK-12228 test seam: under --test, the PAID / external identification endpoints (Gemini/GCV OCR,
+ // CLIP, FileMaker, dw_unified vendor registry) answer with deterministic empty results, so a headless
+ // browser can drive the whole front+back capture flow at $0 with no external reads or writes.
+ if (TEST_MODE && req.method !== 'OPTIONS') {
+ const STUB = {
+ '/api/extract': { ok: true, fields: {}, test_stub: true },
+ '/api/ocr': { ok: true, candidates: [], top: null, topStrong: false, test_stub: true },
+ '/api/identify': { ok: true, found: false, candidates: [], test_stub: true },
+ '/api/identify-multi': { ok: true, found: false, matches: [], results: [], test_stub: true },
+ '/api/recognize': { ok: true, found: false, matches: [], test_stub: true },
+ '/api/learn': { ok: true, test_stub: true },
+ '/api/ask': { ok: true, answer: '(test mode)', test_stub: true },
+ '/api/incoming-samples': { ok: true, samples: [], test_stub: true },
+ '/api/vendors-registry': { ok: true, count: 2, test_stub: true, vendors: [
+ { vendor: 'Test Vendor', real_vendor: 'Test Vendor', vid: 'TST', sku_prefix: 'TST-', sku_range_start: 1000, fm_vid: '999', private_label: false },
+ { vendor: 'Fentucci', real_vendor: 'Fentucci', vid: 'TWIL', sku_prefix: 'GRS-', sku_range_start: 26000, fm_vid: '1', private_label: true }] },
+ };
+ if (STUB[u.pathname]) { req.resume(); return send(res, 200, STUB[u.pathname]); }
+ }
+
// ── Public marketing assets (email images) — TIGHTLY bounded static serve ──
// No auth (allowed in the PUBLIC gate above). NOT a generic file server:
// · Fixed dir public-marketing/ — never user-controlled.
@@ -1429,6 +1552,19 @@ const appHandler = (req, res) => {
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
}
+ // ── Capture history (TK-12228): every item's FRONT + BACK photos, newest first (behind Basic-auth).
+ if (u.pathname === '/captures') {
+ const f = path.join(ROOT, 'public/captures.html');
+ if (!fs.existsSync(f)) return send(res, 404, { err: 'captures.html missing' });
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
+ }
+ if (u.pathname === '/api/captures' && req.method === 'GET') {
+ const limit = Math.min(2000, Math.max(1, parseInt(u.searchParams.get('limit') || '500', 10) || 500));
+ const all = listCaptures();
+ const missingBack = all.filter(c => !c.photos.some(p => p.side === 'back')).length;
+ return send(res, 200, { ok: true, total: all.length, missing_back: missingBack, count: Math.min(limit, all.length), captures: all.slice(0, limit) });
+ }
+
// ── Per-vendor photo archive viewer (behind the global Basic-auth wall).
if (u.pathname === '/vendor-photos') {
const f = path.join(ROOT, 'public/vendor-photos.html');
@@ -1877,12 +2013,24 @@ const appHandler = (req, res) => {
p._photos64 = photos64;
const b64 = photos64[0] || null;
try {
- const out = await createNewItem(p, b64, p.commit !== true);
- if (p.commit === true && out && out.ok) {
+ const out = TEST_MODE ? testStubCreate(p, p.commit !== true) : await createNewItem(p, b64, p.commit !== true);
+ if (p.commit === true && out) {
+ // TK-12228: persist EVERY committed photo (front + back + extras) with its side, even when the
+ // create failed — so nothing captured is ever lost/undisplayable — and return the served URLs
+ // so the result view shows the photos the server actually stored.
const sku = out.dw_sku || (out.preview && out.preview.dw_sku) || p.mfr;
const vend = (out.preview && out.preview.vendor) || out.vendor || p.vendor;
- (p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
- .forEach((ph, i) => archiveVendorB64(ph, { vendor: vend, dw_sku: sku, source: 'create-item', tag: i }));
+ const list = p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []);
+ const sides = sidesFor(list.length, p.front_present, p.back_present);
+ const photos = [];
+ list.forEach((ph, i) => {
+ const sv = savePhotoB64(ph, sku, sides[i], i); if (!sv) return;
+ archiveVendorPhoto(sv.file, { vendor: vend, dw_sku: sku, source: 'create-item' });
+ photos.push({ side: sides[i], url: sv.url });
+ });
+ const rec = recordCapture({ source: 'create-item', dw_sku: sku || null, vendor: vend || null, mfr: p.mfr || null,
+ product_id: out.product_id || null, title: out.title || null, ok: !!out.ok, err: out.ok ? null : (out.err || null), photos });
+ out.captured = { id: rec && rec.id, photos };
}
send(res, 200, out);
}
@@ -2422,12 +2570,12 @@ load();
const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
const b64 = await normB64(dataUrl.replace(/^data:image\/\w+;base64,/, '')); // raw photo → clean JPEG
- const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
- const fname = `${safe}-${Date.now()}.jpg`;
- fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
- archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
+ const side = cleanSide(p.side || (meta && meta.side));
+ const sv = savePhotoB64(b64, dw_sku, side, 0);
+ if (!sv) return send(res, 500, { ok: false, err: 'could not save photo' });
+ archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
const entry = progress[dw_sku] || {};
- entry.done = true; entry.skipped = false; entry.photo = `/photos/${fname}`; entry.ts = new Date().toISOString();
+ entry.done = true; entry.skipped = false; entry.photo = sv.url; entry.ts = new Date().toISOString();
entry.live = false; entry.live_reason = null; entry.created = false;
const sheet = SHEET_BY_GRS[String(dw_sku).toUpperCase()];
const existingPid = product_id || (CATALOG.find(x => (x.dw_sku || '').toUpperCase() === String(dw_sku).toUpperCase()) || {}).product_id;
@@ -2455,7 +2603,9 @@ load();
status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts
});
saveRecents();
- return send(res, 200, { ok: true, dw_sku, photo: entry.photo, created: entry.created, shopify_pushed: entry.shopify_pushed,
+ recordCapture({ source: 'photo', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id: entry.product_id || existingPid || null,
+ title: (meta && meta.title) || null, ok: true, shopify_pushed: !!entry.shopify_pushed, photos: [{ side, url: sv.url }] });
+ return send(res, 200, { ok: true, dw_sku, photo: entry.photo, photos: [{ side, url: sv.url }], created: entry.created, shopify_pushed: entry.shopify_pushed,
push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
});
return;
@@ -2473,13 +2623,13 @@ load();
if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
return send(res, 400, { err: 'dw_sku + product_id + dataUrls[] required' });
const urls = dataUrls.slice(0, 12); // cap a single batch
- let added = 0; const errors = []; let lastPhoto = null;
+ const sides = Array.isArray(p.sides) ? p.sides.map(cleanSide) : urls.map(() => 'photo');
+ let added = 0; const errors = []; let lastPhoto = null; const saved = [];
for (let i = 0; i < urls.length; i++) {
const b64 = await normB64(String(urls[i]).replace(/^data:image\/\w+;base64,/, '')); // raw photo → clean JPEG
- const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
- const fname = `${safe}-${Date.now()}-${i}.jpg`;
- try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`;
- archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); } catch (e) {}
+ const sv = savePhotoB64(b64, dw_sku, sides[i] || 'photo', i);
+ if (sv) { lastPhoto = sv.url; saved.push({ side: sides[i] || 'photo', url: sv.url });
+ archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); }
const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
if (r.ok) added++; else errors.push(r.err);
}
@@ -2492,7 +2642,9 @@ load();
recents[dw_sku] = cardRecord(dw_sku, meta, { product_id, image: entry.photo,
status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts });
saveRecents();
- return send(res, 200, { ok: added > 0, added, total: urls.length, errors, live: entry.live, live_reason: entry.live_reason });
+ recordCapture({ source: 'update', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id,
+ title: (meta && meta.title) || null, ok: added > 0, err: errors[0] || null, photos: saved });
+ return send(res, 200, { ok: added > 0, added, total: urls.length, errors, photos: saved, live: entry.live, live_reason: entry.live_reason });
});
return;
}
@@ -2844,6 +2996,19 @@ function buildFmMaster(p, mint, vreg) {
// Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row + a REAL
// FileMaker WALLPAPER master. Draft-only, never auto-published (going live is a separate gated step).
// dryRun (default) returns a full 3-system preview and writes nothing.
+// TK-12228 test seam: stands in for createNewItem under --test ONLY (no Shopify / dw_unified / FileMaker).
+function testStubCreate(p, dryRun) {
+ const mfr = String(p.mfr || '').trim(), vendor = String(p.vendor || '').trim();
+ if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
+ const dw = 'TEST-' + mfr.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
+ const preview = { title: `${p.name || mfr} | ${vendor}`, dw_sku: dw, mfr, vendor, vid: p.vid || null, color: p.color || '', price: null, status: 'draft',
+ photos: Array.isArray(p._photos64) ? p._photos64.length : 0, provisional_sku: false, duplicate_of: null,
+ shopify: { vendor, product_type: 'Wallcovering', status: 'draft', variants: [{ option1: 'Roll', sku: dw, price: '(quote)' }, { option1: 'Sample', sku: dw + '-Sample', price: '4.25' }] },
+ staging: { table: 'new_items_staging', dw_sku: dw, mfr_sku: mfr, vendor, vid: '', pattern_name: p.name || '', color: p.color || '', price: null, specs: {} },
+ filemaker: { db: 'TEST', layout: 'TEST', fieldData: {}, auto_calc: {} }, flags: ['TEST MODE — nothing written'] };
+ if (dryRun) return { ok: true, dryRun: true, preview };
+ return { ok: true, test_stub: true, product_id: null, dw_sku: dw, title: preview.title, status: 'draft', 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 — pick a vendor first' };
@@ -3448,8 +3613,9 @@ server.on('error', (e) => {
console.error('[server.on error]', (e && e.stack) || e);
});
server.listen(PORT, '0.0.0.0', () => {
- console.log(`DW Photo Capture on http://0.0.0.0:${PORT} (Shopify push: ${TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
+ console.log(`DW Photo Capture on http://0.0.0.0:${PORT} (Shopify push: ${TEST_MODE ? 'STUBBED (--test)' : TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
rebuildIndex(); // sheet-only items searchable immediately
+ if (TEST_MODE) return; // TK-12228: no catalog fetch / multi-hundred-MB cache write in test mode
buildCatalog(); // load the WHOLE dw_unified catalog from the all-dw feed
// keep the browsable catalog fresh (feed refreshes its own snapshot every 10 min)
setInterval(() => buildCatalog().catch(() => {}), 15 * 60 * 1000);
← e82585f deploy: exclude .claude/ from rsync so agent worktree logs o
·
back to Dw Photo Capture
·
5x sweep 1: favicon on captures/batch/cam, consistent missin 15beb44 →