← back to Dw Photo Capture
public/simple-probooth.html
536 lines
<!doctype html>
<!--
═══════════════════════════════════════════════════════════════════════════════════════════
PRO BOOTH — simplified capture for DW staff who want SOME control, not the full toolkit.
TK-12162 concept page (1 of 4 explored directions). LOCAL-ONLY until Steve picks a direction.
What it is:
· One focused job: photograph a wallcovering sample → Shopify DRAFT. Nothing else.
· Exactly THREE big always-visible sliders — Exposure · Warmth · Saturation — mapped onto
the EXISTING shared CapturePipeline engine (/js/capture-pipeline.js, TK-12115). Same
math, same WYSIWYG guarantee, none of the 11-slider panel.
· No vendor picker, no OCR/extraction UI, no multi-shot batch mode. Vendor/mfr#/name/color
arrive by background auto-fill (server /api/extract) into four plain text fields.
SPEED ARCHITECTURE (the Jev principle — one decisive network exchange, minimum round trips):
1. The adjustment math bakes EXACTLY ONCE per capture, at shutter time, full-res
(CapturePipeline.apply with capture:true). The result screen shows the already-baked
JPEG — nothing is re-processed after capture. (The ~80ms live preview necessarily runs
the same apply() per frame at ≤640px — that is what makes it WYSIWYG — but post-shutter
there is zero further pixel work.)
2. The FIRST network call (/api/extract with the baked JPEG) fires SPECULATIVELY inside
the shutter handler, in the same tick the bake finishes — BEFORE the confirm/retake
screen appears. Retake aborts it via AbortController (cancel-if-retake pattern:
ATTEMPTED AND SHIPPED — extract is idempotent + read-only, so speculation is free).
3. Save Draft is a SINGLE /api/create-item {commit:true} — Pro Booth deliberately skips
the desk tool's extra dry-run "preview" round trip (index.html does preview→commit =
2 sequential create-item calls). Safe because the server result is a DRAFT (never
auto-published) and the server dedups on mfr# either way.
4. /api/create-item itself could NOT be fired speculatively at shutter: it requires
mfr+vendor, which don't exist until extract returns or the operator types them. The
speculative slot therefore goes to extract — the only call that's ready at shutter.
HARD RULES preserved from the existing pipeline (see /js/capture-pipeline.js header):
· manual canvas pixel math only — NO WebGL, NO ctx.filter, NO CSS filters (ctx.filter
doesn't survive toDataURL() and is unreliable on iOS Safari).
· preview == capture (same apply(), same tune).
═══════════════════════════════════════════════════════════════════════════════════════════
-->
<html lang="en">
<head>
<meta charset="utf-8">
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#111008">
<title>DW Pro Booth</title>
<style>
:root{
--bg:#111008; --panel:#1b1912; --line:#2c2920; --ink:#f2ede1; --dim:#9a917d;
--gold:#c8a24a; --gold2:#e2bd66; --ok:#5fbf7a; --err:#e0483a;
--rail:132px;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--ink);
font:15px/1.45 -apple-system,'SF Pro Text','Helvetica Neue',Helvetica,Arial,sans-serif;
overscroll-behavior:none}
body{position:fixed;inset:0;overflow:hidden;
padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
.screen{position:absolute;inset:0;display:flex;flex-direction:column}
[hidden]{display:none!important}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
/* ── top bar (shared) ── */
.bar{display:flex;align-items:center;gap:10px;padding:12px 16px;flex:0 0 auto}
.brand{font-size:12px;letter-spacing:.34em;color:var(--gold);font-weight:600;text-transform:uppercase;white-space:nowrap}
.brand b{color:var(--ink);font-weight:600}
.bar .sp{flex:1}
.chip{border:1px solid var(--line);border-radius:999px;padding:7px 14px;font-size:12.5px;color:var(--dim);background:rgba(0,0,0,.35)}
.chip:active{color:var(--ink);border-color:var(--gold)}
.bar .chip{white-space:nowrap}
/* ── LIVE ── */
#live{background:#000}
.stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden}
#pv{max-width:100%;max-height:100%;display:block;background:#000}
#vid{display:none}
.stage .hint{position:absolute;left:0;right:0;bottom:10px;text-align:center;font-size:12px;color:rgba(242,237,225,.75);
text-shadow:0 1px 4px rgba(0,0,0,.8);pointer-events:none}
#camMsg{position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;
text-align:center;padding:28px;color:var(--dim);font-size:14px}
#camMsg button{border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:12px 22px;font-size:15px}
/* ── controls: the 3 sliders + shutter ── */
.controls{flex:0 0 auto;background:linear-gradient(0deg,#15130c 82%,rgba(21,19,12,0));padding:6px 16px 14px;
display:flex;flex-direction:column;gap:2px}
.sl{display:grid;grid-template-columns:96px 1fr 52px;align-items:center;gap:12px;padding:7px 0}
.sl label{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);font-weight:600}
.sl output{font-size:14px;color:var(--gold2);text-align:right;font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:44px;background:transparent;touch-action:none}
input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;
background:linear-gradient(90deg,var(--line) 0 49.5%,var(--gold) 49.5% 50.5%,var(--line) 50.5% 100%)}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:30px;height:30px;border-radius:50%;
background:radial-gradient(circle at 34% 30%,#f0d489,var(--gold) 62%);border:2px solid #171509;margin-top:-13px;
box-shadow:0 2px 8px rgba(0,0,0,.55)}
input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:var(--line)}
input[type=range]::-moz-range-thumb{width:30px;height:30px;border-radius:50%;background:var(--gold);border:2px solid #171509}
.shutter-row{display:flex;align-items:center;justify-content:center;gap:26px;padding-top:8px}
#shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--ink);position:relative;flex:0 0 auto}
#shutter::after{content:'';position:absolute;inset:6px;border-radius:50%;background:var(--ink);transition:transform .08s}
#shutter:active::after{transform:scale(.86)}
#shutter:disabled{opacity:.35}
.side-slot{width:78px;display:flex;justify-content:center}
#fullResBtn{white-space:nowrap}
/* iPad / landscape: sliders become a calm right rail, shutter below them */
@media (orientation:landscape) and (min-width:700px){
#live{flex-direction:row}
#live .col-main{flex:1;display:flex;flex-direction:column;min-width:0}
.controls{flex:0 0 236px;background:var(--panel);border-left:1px solid var(--line);
padding:18px 20px calc(18px + env(safe-area-inset-bottom));justify-content:center;gap:6px}
.sl{grid-template-columns:1fr 52px;grid-template-rows:auto auto;padding:10px 0}
.sl label{grid-column:1}
.sl output{grid-column:2}
.sl input{grid-column:1 / span 2;grid-row:2}
.shutter-row{padding-top:22px}
}
/* ── RESULT ── */
#result{background:var(--bg)}
.r-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding:0 16px 16px;overflow:auto}
.r-photo{flex:0 0 auto;display:flex;justify-content:center}
.r-photo img{max-width:100%;max-height:44vh;border-radius:12px;border:1px solid var(--line)}
.f-strip{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.f{display:flex;flex-direction:column;gap:5px}
.f span{font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim);font-weight:600}
.f input{background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink);
font:inherit;font-size:16px;padding:12px 12px;min-height:46px;width:100%}
.f input:focus{outline:none;border-color:var(--gold)}
.f input.need{border-color:var(--err)}
#xstat{font-size:12.5px;color:var(--dim);min-height:18px}
#xstat.on{color:var(--gold2)}
.r-actions{display:flex;gap:12px;padding-top:2px}
.btn{flex:1;border-radius:12px;padding:16px;font-size:16px;font-weight:600;text-align:center;min-height:54px}
.btn.ghost{border:1px solid var(--line);color:var(--dim)}
.btn.ghost:active{color:var(--ink)}
.btn.gold{background:var(--gold);color:#171308}
.btn.gold:active{background:var(--gold2)}
.btn:disabled{opacity:.4}
#timing{font-size:11px;color:#6f684f;font-variant-numeric:tabular-nums;text-align:center;padding-bottom:4px}
@media (orientation:landscape) and (min-width:700px){
.r-body{flex-direction:row;align-items:stretch;gap:22px;padding:0 22px 18px}
.r-photo{flex:1.2;align-items:center}
.r-photo img{max-height:100%}
.r-form{flex:1;display:flex;flex-direction:column;gap:14px;justify-content:center;max-width:430px}
}
/* ── SAVED ── */
#saved{align-items:center;justify-content:center;gap:16px;text-align:center;padding:24px}
#saved .big{font-size:52px}
#saved .sku{font-size:26px;font-weight:700;color:var(--gold2);letter-spacing:.04em}
#saved .sub{color:var(--dim);font-size:13.5px;max-width:340px}
#saved .btn{flex:0 0 auto;min-width:240px}
#sessN{font-size:12px;color:var(--dim)}
/* toast */
#toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom));transform:translate(-50%,20px);
background:#232015;border:1px solid var(--line);color:var(--ink);border-radius:12px;
padding:12px 18px;font-size:14px;opacity:0;pointer-events:none;transition:.22s;z-index:99;max-width:86vw;text-align:center}
#toast.on{opacity:1;transform:translate(-50%,0)}
.flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:50}
.flash.on{opacity:.85;transition:none}
</style>
</head>
<body>
<!-- ════════ SCREEN 1 · LIVE ════════ -->
<div class="screen" id="live">
<div class="col-main">
<div class="bar">
<div class="brand">DW <b>Pro Booth</b></div>
<div class="sp"></div>
<button class="chip" id="resetBtn" title="Reset adjustments">↺ Reset</button>
<button class="chip" id="homeBtn" title="Back to full app">✕</button>
</div>
<div class="stage">
<video id="vid" autoplay playsinline muted></video>
<canvas id="pv"></canvas>
<div class="flash" id="flash"></div>
<div class="hint" id="liveHint">Frame the sample · adjust · shoot</div>
<div id="camMsg" hidden>
<div id="camMsgTxt">Camera unavailable.</div>
<button id="snapFallback">📷 Use the snap camera</button>
<input type="file" id="fileInput" accept="image/*" capture="environment" hidden>
</div>
</div>
</div>
<div class="controls">
<div class="sl">
<label for="sExposure">Exposure</label>
<input type="range" id="sExposure" min="-100" max="100" step="1" value="0">
<output id="oExposure">0</output>
</div>
<div class="sl">
<label for="sWarmth">Warmth</label>
<input type="range" id="sWarmth" min="-100" max="100" step="1" value="0">
<output id="oWarmth">0</output>
</div>
<div class="sl">
<label for="sSaturation">Saturation</label>
<input type="range" id="sSaturation" min="-100" max="100" step="1" value="0">
<output id="oSaturation">0</output>
</div>
<div class="shutter-row">
<div class="side-slot"><button class="chip" id="fullResBtn">📷 Full-res</button></div>
<button id="shutter" aria-label="Take photo" disabled></button>
<div class="side-slot"><span id="sessN"></span></div>
</div>
</div>
</div>
<!-- ════════ SCREEN 2 · RESULT ════════ -->
<div class="screen" id="result" hidden>
<div class="bar">
<div class="brand">DW <b>Pro Booth</b></div>
<div class="sp"></div>
<div class="chip" style="border-color:transparent">baked · full res</div>
</div>
<div class="r-body">
<div class="r-photo"><img id="shotImg" alt="Captured sample"></div>
<div class="r-form">
<div id="xstat">Reading the label…</div>
<div class="f-strip">
<div class="f"><span>Vendor</span><input id="fVendor" autocomplete="off" placeholder="required"></div>
<div class="f"><span>Mfr # / SKU</span><input id="fMfr" autocomplete="off" placeholder="required"></div>
<div class="f"><span>Pattern name</span><input id="fName" autocomplete="off" placeholder="optional"></div>
<div class="f"><span>Color</span><input id="fColor" autocomplete="off" placeholder="optional"></div>
</div>
<div class="r-actions">
<button class="btn ghost" id="retakeBtn">↩ Retake</button>
<button class="btn gold" id="saveBtn">Save draft</button>
</div>
<div id="timing"></div>
</div>
</div>
</div>
<!-- ════════ SCREEN 3 · SAVED ════════ -->
<div class="screen" id="saved" hidden>
<div class="big">✓</div>
<div class="sku" id="savedSku">DW—</div>
<div class="sub" id="savedDwUni" hidden></div>
<div class="sub">Saved as a Shopify <b>draft</b> — never auto-published. Adjustments and camera stay warm for the next sample.</div>
<button class="btn gold" id="nextBtn">Next sample</button>
<div id="sessN2"></div>
</div>
<div id="toast"></div>
<script src="/js/capture-pipeline.js"></script>
<script src="/js/acquire-camera.js"></script>
<script src="/js/field-clean.js"></script>
<script src="/js/native-photo.js"></script>
<script src="/js/adjust-panel.js"></script>
<script>
'use strict';
/* Pro Booth client — see the header comment for the speed architecture. */
const $ = s => document.querySelector(s);
let toastT;
function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('on'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('on'),2600); }
/* ── the trimmed tune: 3 controls over the FULL shared engine (everything else stays 0) ──
Exposure -> tune.exposure · Warmth -> tune.temp · Saturation -> tune.saturation.
Persisted under its own key so Pro Booth never clobbers the desk tool's dwTsTune. */
const TUNE_KEY='dwProBoothTune';
let tune = CapturePipeline.loadTune(TUNE_KEY);
const SLIDERS=[['sExposure','exposure','oExposure'],['sWarmth','temp','oWarmth'],['sSaturation','saturation','oSaturation']];
function syncSliders(){ for(const [id,key,out] of SLIDERS){ $('#'+id).value=tune[key]; $('#'+out).textContent=tune[key]; } }
function bindSliders(){
for(const [id,key,out] of SLIDERS){
const el=$('#'+id);
el.addEventListener('input',()=>{
tune[key]=parseInt(el.value,10)||0;
$('#'+out).textContent=tune[key];
CapturePipeline.saveTune(TUNE_KEY,tune); panel.sync();
hwNudge();
});
}
$('#resetBtn').addEventListener('click',()=>{
tune=CapturePipeline.defaultTune(); CapturePipeline.saveTune(TUNE_KEY,tune);
syncSliders(); panel.sync(); hwNudge(); toast('Adjustments reset');
});
}
/* best-effort hardware assist (never authoritative — the software bake owns WYSIWYG) */
let hwT=null;
function hwNudge(){ clearTimeout(hwT); hwT=setTimeout(()=>{ try{ const tr=acq.getTrack(); if(tr) CapturePipeline.Hardware.apply(tr,tune); }catch(e){} },250); }
/* all 11 adjustments: the panel edits the SAME tune object the 3 big sliders, preview and bake use */
const panel = AdjustPanel.create({ getTune:()=>tune, toggleText:'🎚 All 11', toggleParent: document.querySelector('#live .bar'), toggleClass:'chip',
onChange:()=>{ CapturePipeline.saveTune(TUNE_KEY,tune); syncSliders(); hwNudge(); } });
$('#resetBtn').before(panel.toggle);
/* ── camera (shared hardened acquirer — bounded getUserMedia + play, generation-guarded) ── */
const acq = AcquireCamera.createCameraAcquirer();
let live=false, pvTimer=null, wake=null, fallbackShot=null;
async function startCam(){
live=false; $('#shutter').disabled=true; $('#camMsg').hidden=true;
if(!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
return camDead('Live camera needs HTTPS (Safari on iPad/iPhone).');
}
try{
await acq.acquire(
[ {video:{facingMode:{ideal:'environment'},width:{ideal:4096},height:{ideal:3072}},audio:false},
{video:{facingMode:{ideal:'environment'}},audio:false},
{video:true,audio:false} ],
{ videoEl: $('#vid'), onDead: ()=>camDead('Camera stopped — tap to restart.') });
}catch(e){
return camDead(e && e.message==='camera-timeout' ? 'Camera didn’t respond — tap to retry, or use the snap camera.' : 'Camera blocked — allow camera access, or use the snap camera.');
}
live=true; $('#shutter').disabled=false;
if(pvTimer) clearInterval(pvTimer);
pvTimer=setInterval(previewTick,80);
hwNudge();
try{ if('wakeLock' in navigator) wake=await navigator.wakeLock.request('screen'); }catch(e){}
}
function camDead(msg){
live=false; $('#shutter').disabled=true;
$('#camMsgTxt').textContent=msg; $('#camMsg').hidden=false;
}
$('#camMsg').addEventListener('click',e=>{ if(e.target.id!=='snapFallback') startCam(); });
$('#snapFallback').addEventListener('click',()=>$('#fileInput').click());
/* snap-camera fallback: the file still goes through the SAME one-time bake (sliders apply) */
$('#fileInput').addEventListener('change',async ev=>{
const f=ev.target.files && ev.target.files[0]; if(!f) return;
const url=await new Promise(res=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.readAsDataURL(f); });
const img=await new Promise(res=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=url; });
if(!img) return toast('Could not read that photo');
const baked=bakeFrom(img);
if(baked) shutterLand(baked);
ev.target.value='';
});
/* ── live WYSIWYG preview — same apply(), ≤640px, capture:false (per the shared-engine contract) ── */
function previewTick(){
if(!live || !$('#result').hidden || !$('#saved').hidden) return;
const v=$('#vid'), w=v.videoWidth, h=v.videoHeight; if(!w||!h) return;
const scale=Math.min(1,640/Math.max(w,h)), pw=Math.round(w*scale), ph=Math.round(h*scale);
const pv=$('#pv'); if(pv.width!==pw)pv.width=pw; if(pv.height!==ph)pv.height=ph;
const x=pv.getContext('2d',{willReadFrequently:true});
try{
CapturePipeline.drawSource(x,v,pw,ph,tune.straighten);
CapturePipeline.apply(x,pw,ph,tune,{capture:false});
}catch(e){}
}
/* ── THE BAKE — exactly once per capture, full res, at shutter time ── */
const MAX_EDGE=2000; // same 25MB-body-cap guard as the desk tool
function bakeFrom(source, maxEdge){
const sw=source.videoWidth||source.naturalWidth||source.width;
const sh=source.videoHeight||source.naturalHeight||source.height;
if(!sw||!sh) return null;
let w=sw,h=sh; const m=Math.max(w,h);
const cap=maxEdge||MAX_EDGE;
if(m>cap){ const k=cap/m; w=Math.round(w*k); h=Math.round(h*k); }
const c=document.createElement('canvas'); c.width=w; c.height=h;
const ctx=c.getContext('2d',{willReadFrequently:true});
const t0=performance.now();
try{
CapturePipeline.drawSource(ctx,source,w,h,tune.straighten);
CapturePipeline.apply(ctx,w,h,tune,{capture:true}); // ← the ONE full-res bake
}catch(e){ return null; }
const url=c.toDataURL('image/jpeg',0.92);
perf.bakeMs=Math.round(performance.now()-t0);
perf.px=w+'×'+h;
return url;
}
/* ── perf instrumentation (shown small on the result screen; logged to console) ── */
let perf={};
/* ── speculative extract: fired in the SAME handler tick as the bake, aborted on retake ── */
let xCtl=null, xPromise=null, shotUrl=null;
function fireExtract(url){
xCtl=new AbortController();
perf.extractStartMs=Math.round(performance.now()-perf.shutterT0);
const p=fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dataUrl:url}),signal:xCtl.signal})
.then(r=>r.json())
.then(r=>{ perf.extractMs=Math.round(performance.now()-perf.shutterT0-perf.extractStartMs); return r; })
.catch(e=>({ok:false,aborted:e&&e.name==='AbortError',err:String(e&&e.message||e)}));
xPromise=p;
p.then(r=>{
if(xPromise!==p || $('#result').hidden) return; // stale (retaken) — ignore
const st=$('#xstat');
if(r.aborted) return;
if(!r || !r.ok || !r.fields){ st.textContent='Label read unavailable — type the vendor + mfr#.'; st.classList.remove('on'); showTiming(); return; }
const f=r.fields;
fillIfEmpty('fVendor', r.vendor_matched || f.vendor);
fillIfEmpty('fMfr', f.mfr_sku);
fillIfEmpty('fName', f.pattern_name);
fillIfEmpty('fColor', f.color);
extracted=f;
st.textContent='✓ Label read — check the fields, then Save.'; st.classList.add('on');
showTiming();
if(panel.autoSave() && r.vendor_registered===true && $('#fVendor').value.trim() && $('#fMfr').value.trim() && !$('#saveBtn').disabled){
st.textContent='✓ Label read — saving draft…'; $('#saveBtn').click();
}
});
}
let extracted={};
function fillIfEmpty(id,v){ const el=$('#'+id); const c=cleanField(v); if(el && !el.value.trim() && c) el.value=c; }
function showTiming(){
const t=[];
if(perf.bakeMs!=null) t.push('bake '+perf.bakeMs+'ms @ '+perf.px);
if(perf.extractStartMs!=null) t.push('upload started +'+perf.extractStartMs+'ms after shutter');
if(perf.extractMs!=null) t.push('extract '+perf.extractMs+'ms');
if(perf.saveMs!=null) t.push('save '+perf.saveMs+'ms');
$('#timing').textContent=t.join(' · ');
if(t.length) console.log('[probooth perf]',t.join(' · '));
}
/* ── full-res: the phone's own Camera app photo, baked ONCE with the same 3 sliders at up to
NativePhoto.FULL_EDGE; the unbaked 1600px copy feeds the label read (OCR ignores tone). ── */
$('#fullResBtn').addEventListener('click',()=>{
NativePhoto.pick().then(p=>{
if(!p) return;
const img=new Image();
img.onload=()=>{
perf={shutterT0:performance.now()};
const baked=bakeFrom(img, NativePhoto.FULL_EDGE);
if(baked) shutterLand(baked, p.small); else toast('Could not process that photo');
};
img.onerror=()=>toast('Could not read that photo');
img.src=p.full;
});
});
/* ── shutter ── */
$('#shutter').addEventListener('click',()=>{
if(!live) return;
perf={shutterT0:performance.now()};
const fl=$('#flash'); fl.classList.add('on'); setTimeout(()=>fl.classList.remove('on'),120);
if(navigator.vibrate) try{ navigator.vibrate(30); }catch(e){}
const url=bakeFrom($('#vid'));
if(!url) return toast('Capture failed — try again');
shutterLand(url);
});
function shutterLand(url, extractUrl){
shotUrl=url; extracted={};
if(!perf.shutterT0) perf={shutterT0:performance.now()};
fireExtract(extractUrl||url); // ← network starts NOW, before the result screen paints
$('#shotImg').src=url;
['fVendor','fMfr','fName','fColor'].forEach(id=>{ const el=$('#'+id); el.value=''; el.classList.remove('need'); });
$('#xstat').textContent='Reading the label…'; $('#xstat').classList.remove('on');
$('#timing').textContent=''; showTiming();
$('#saveBtn').disabled=false;
$('#live').hidden=true; $('#result').hidden=false;
}
/* ── retake: cancel the speculative upload, straight back to live ── */
$('#retakeBtn').addEventListener('click',()=>{
if(xCtl){ try{ xCtl.abort(); }catch(e){} xCtl=null; }
xPromise=null; shotUrl=null;
$('#result').hidden=true; $('#live').hidden=false;
if(!live) startCam();
});
/* ── save: ONE decisive commit call (no dry-run preview round trip) ── */
let sess=0;
$('#saveBtn').addEventListener('click',async ()=>{
const vendor=$('#fVendor').value.trim(), mfr=$('#fMfr').value.trim();
$('#fVendor').classList.toggle('need',!vendor); $('#fMfr').classList.toggle('need',!mfr);
if(!vendor||!mfr){ toast('Vendor + mfr# are required'); return; }
const b=$('#saveBtn'); b.disabled=true; $('#xstat').textContent='Saving draft…'; $('#xstat').classList.add('on');
const t0=performance.now();
const payload=Object.assign({}, extracted, {
vendor, mfr,
name:$('#fName').value.trim(), color:$('#fColor').value.trim(),
dataUrl:shotUrl, photos:[shotUrl],
require_two:true, front_present:true, back_present:false,
id_source: extracted && extracted.mfr_sku ? 'ocr' : 'manual',
notes:'Captured via Pro Booth',
commit:true
});
let r;
try{ r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})).json(); }
catch(e){ r={ok:false,err:'network — '+(e&&e.message||e)}; }
perf.saveMs=Math.round(performance.now()-t0); showTiming();
if(r && r.duplicate){ $('#xstat').textContent='⚠ '+(r.err||'Already in the catalog')+' — use the full app’s Update SKU to add media.'; $('#xstat').classList.remove('on'); b.disabled=false; return; }
if(!r || !r.ok){ $('#xstat').textContent='✗ '+((r&&r.err)||'save failed'); $('#xstat').classList.remove('on'); b.disabled=false; return; }
sess++;
$('#savedSku').textContent=r.dw_sku||'saved';
// dw_unified is a SEPARATE write from the Shopify draft (server.js createNewItem stages into
// new_items_staging) — r.ok only means the Shopify draft succeeded, so it's shown independently.
const dwuEl=$('#savedDwUni');
if(r.dw_unified){ dwuEl.hidden=false; dwuEl.style.color=r.dw_unified.committed?'var(--ok)':'var(--err)';
dwuEl.textContent=r.dw_unified.committed?'✓ saved to dw_unified':'⚠ dw_unified write failed'; }
else dwuEl.hidden=true;
$('#sessN').textContent=sess+' ✓'; $('#sessN2').textContent=sess+' saved this session';
$('#result').hidden=true; $('#saved').hidden=false;
toast('✅ Draft created'+(r.dw_sku?': '+r.dw_sku:''));
});
/* ── next: adjustments + camera stay warm ── */
$('#nextBtn').addEventListener('click',()=>{
shotUrl=null; extracted={}; perf={};
$('#saved').hidden=true; $('#live').hidden=false;
if(!live) startCam();
});
$('#homeBtn').addEventListener('click',()=>{ location.href='/app'; });
/* iOS backgrounding pauses the stream — re-assert on return */
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='visible' && !$('#live').hidden && acq.getStream()){
$('#vid').play().catch(()=>{});
try{ if('wakeLock' in navigator) navigator.wakeLock.request('screen').then(w=>wake=w).catch(()=>{}); }catch(e){}
}
});
/* boot */
syncSliders(); bindSliders(); startCam();
</script>
</body>
</html>