← back to Dw Photo Capture
public/simple-minimal.html
468 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in, mandatory on every Basic-Auth-gated DW page) — 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 a 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. 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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DW Photo">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f0e0c">
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<title>DW Photo — Minimal Showroom</title>
<script src="/js/field-clean.js"></script>
<script src="/js/native-photo.js"></script>
<script src="/js/capture-pipeline.js"></script>
<script src="/js/adjust-panel.js"></script>
<style>
:root{ --bg:#0f0e0c; --panel:#1b1710; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184;
--line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;overscroll-behavior:none}
body{margin:0;background:#000;color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;
-webkit-text-size-adjust:100%;user-select:none}
/* ============ STAGE — full-bleed camera / captured photo, zero chrome ============ */
#stage{position:fixed;inset:0;background:#000;overflow:hidden}
#cam{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000}
#cam.mirror{transform:scaleX(-1)}
#shot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;opacity:0;pointer-events:none;transition:opacity .15s}
#shot.on{opacity:1}
#flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;z-index:40}
#flash.on{opacity:.9;transition:opacity .35s ease-out}
/* Vignette so the shutter/gear always read regardless of what the camera sees */
#stage::before{content:'';position:absolute;left:0;right:0;bottom:0;height:34%;z-index:5;pointer-events:none;
background:linear-gradient(180deg,transparent,rgba(0,0,0,.55) 70%)}
#stage::after{content:'';position:absolute;left:0;right:0;top:0;height:14%;z-index:5;pointer-events:none;
background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)}
/* ============ GEAR — the ONE tucked-away affordance, top-right corner ============ */
#gear{position:absolute;top:calc(env(safe-area-inset-top,0px) + 14px);right:calc(env(safe-area-inset-right,0px) + 14px);
z-index:30;width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.22);
background:rgba(15,14,12,.5);backdrop-filter:blur(6px);color:rgba(255,255,255,.75);font-size:20px;
display:flex;align-items:center;justify-content:center;cursor:pointer}
#gear:active{background:rgba(15,14,12,.75)}
#drawer{position:absolute;top:calc(env(safe-area-inset-top,0px) + 64px);right:calc(env(safe-area-inset-right,0px) + 14px);
z-index:30;width:min(78vw,300px);background:var(--panel);border:1px solid var(--line);border-radius:16px;
box-shadow:0 10px 34px rgba(0,0,0,.6);padding:14px;display:none;gap:12px;flex-direction:column}
#drawer.open{display:flex}
#drawer .d-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
#drawer .d-lbl{font-size:13px;color:var(--muted);font-weight:600}
#drawer button.d-btn{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:9px;
padding:9px 12px;font-size:13px;font-weight:600;cursor:pointer}
#drawer button.d-btn.on{border-color:var(--gold);color:var(--gold)}
#drawer .d-hd{font:800 13px/1 "SF Pro Display",sans-serif;color:var(--gold);letter-spacing:.03em;text-transform:uppercase;margin-bottom:2px}
/* toggle switch */
.sw{position:relative;width:44px;height:26px;border-radius:99px;background:#3a352c;border:1px solid var(--line);cursor:pointer;flex:0 0 auto}
.sw i{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:var(--muted);transition:left .15s,background .15s}
.sw.on{background:#4a3e22}
.sw.on i{left:20px;background:var(--gold)}
/* ============ SHUTTER — the whole interaction, bottom-center, thumb-reachable ============ */
#shutterWrap{position:absolute;left:0;right:0;bottom:0;z-index:20;display:flex;justify-content:center;
padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
#shutter{width:132px;height:132px;border-radius:50%;border:6px solid var(--gold);background:var(--gold);
box-shadow:0 0 0 4px rgba(0,0,0,.4),0 6px 24px rgba(0,0,0,.5);cursor:pointer;flex:0 0 auto;
transition:transform .08s ease}
#shutter:active{transform:scale(.93)}
#shutter:disabled{background:#3a3428;border-color:#3a3428;opacity:.7}
#shutter[hidden]{display:none}
/* full-res: opens the phone's own Camera app (real sensor photo, not a video frame) */
#btnFull{position:absolute;right:16px;bottom:calc(max(20px,env(safe-area-inset-bottom)) + 170px);
border:1px solid var(--gold);color:var(--gold);background:rgba(0,0,0,.55);border-radius:999px;
padding:10px 16px;font:600 14px/1 -apple-system,system-ui,sans-serif}
#shutter[hidden] + #btnFull{display:none}
/* ============ PROCESSING — pulsing gold status, no other chrome ============ */
#procWrap{position:absolute;left:0;right:0;bottom:0;z-index:25;display:none;flex-direction:column;
align-items:center;gap:14px;padding-bottom:calc(max(20px,env(safe-area-inset-bottom)) + 40px)}
#procWrap.on{display:flex}
.pulse-dot{width:14px;height:14px;border-radius:50%;background:var(--gold);box-shadow:0 0 16px 4px rgba(200,162,74,.7);
animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{transform:scale(.8);opacity:.55}50%{transform:scale(1.15);opacity:1}}
#procMsg{color:#f0e2b8;font:700 16px/1 -apple-system,sans-serif;text-shadow:0 1px 3px #000;letter-spacing:.01em}
#procTime{color:var(--muted);font:600 12px/1 ui-monospace,Menlo,monospace}
#procTime[hidden]{display:none}
/* ============ RESULT — one card, one action ============ */
#resultWrap{position:absolute;inset:0;z-index:26;display:none;align-items:flex-end;justify-content:center;
padding:0 16px calc(max(20px,env(safe-area-inset-bottom)) + 22px)}
#resultWrap.on{display:flex}
.rcard{width:100%;max-width:520px;background:var(--panel);border:1px solid var(--line);border-radius:20px;
padding:20px;box-shadow:0 14px 40px rgba(0,0,0,.6);display:flex;flex-direction:column;gap:14px;
animation:riseIn .22s ease-out}
@keyframes riseIn{from{transform:translateY(18px);opacity:0}to{transform:translateY(0);opacity:1}}
.rcard.ok .rc-check{color:var(--green)}
.rcard.err .rc-check{color:var(--red)}
.rc-check{font-size:34px;line-height:1}
.rc-title{font:800 19px/1.25 "SF Pro Display",sans-serif;color:var(--ink)}
.rc-sub{font:600 13px/1.4 ui-monospace,Menlo,monospace;color:var(--gold)}
.rc-detail{font-size:13px;color:var(--muted)}
.rc-dwuni{font:600 11px/1 -apple-system,sans-serif;margin-top:4px}
.rc-dwuni.ok{color:#8fbf7a}
.rc-dwuni.warn{color:#e0a53a}
.rc-dwuni[hidden]{display:none}
.rc-time{font:600 11px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
.rc-time[hidden]{display:none}
.rc-again{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:16px;
font-size:16px;font-weight:800;cursor:pointer;min-height:56px}
.rc-manual{display:none;flex-direction:column;gap:10px}
.rc-manual.on{display:flex}
.rc-manual select,.rc-manual input{width:100%;background:#16140f;border:1px solid var(--line);color:var(--ink);
border-radius:10px;padding:13px 12px;font-size:15px;min-height:48px}
.rc-manual select.need,.rc-manual input.need{border-color:var(--red);box-shadow:0 0 0 3px rgba(192,86,63,.3)}
.rc-save{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:13px;padding:14px;
font-size:15px;font-weight:700;cursor:pointer;min-height:50px}
/* ============ PERMISSION / ERROR full-screen fallback ============ */
#permWrap{position:fixed;inset:0;z-index:60;background:var(--bg);display:none;flex-direction:column;
align-items:center;justify-content:center;gap:16px;text-align:center;padding:24px}
#permWrap.on{display:flex}
#permWrap h1{font:800 20px/1.3 "SF Pro Display",sans-serif;color:var(--gold);margin:0}
#permWrap p{color:var(--muted);margin:0;max-width:320px;font-size:14px}
#permRetry{background:var(--gold);color:#1b1407;border:none;border-radius:13px;padding:14px 26px;
font-size:15px;font-weight:800;cursor:pointer;min-height:52px}
</style>
</head>
<body>
<div id="stage">
<video id="cam" autoplay playsinline muted></video>
<img id="shot" alt="">
<div id="flash"></div>
</div>
<button id="gear" aria-label="Settings" title="Settings">⚙</button>
<div id="drawer">
<div class="d-hd">Camera</div>
<div class="d-row"><span class="d-lbl">Flip camera</span><button class="d-btn" id="btnFlip">↺ Flip</button></div>
<div class="d-hd" style="margin-top:6px">Behavior</div>
<div class="d-row"><span class="d-lbl">Auto-create on read</span><div class="sw on" id="swAuto"><i></i></div></div>
<div class="d-row"><span class="d-lbl">Show timing</span><div class="sw" id="swTiming"><i></i></div></div>
<div class="d-hd" style="margin-top:6px">Vendor override</div>
<select id="vendorOverride"><option value="">Auto-detect from label</option></select>
</div>
<div id="shutterWrap"><button id="shutter" aria-label="Capture"></button><button id="btnFull">📷 Full-res</button></div>
<div id="procWrap">
<div class="pulse-dot"></div>
<div id="procMsg">Reading label…</div>
<div id="procTime" hidden></div>
</div>
<div id="resultWrap">
<div class="rcard" id="rcard">
<div class="rc-check" id="rcIcon">✓</div>
<div class="rc-title" id="rcTitle">Created</div>
<div class="rc-sub" id="rcSku"></div>
<div class="rc-detail" id="rcDetail"></div>
<div class="rc-dwuni" id="rcDwUni" hidden></div>
<div class="rc-time" id="rcTime" hidden></div>
<div class="rc-manual" id="rcManual">
<select id="mVendor"><option value="">Pick vendor…</option></select>
<input id="mMfr" type="text" placeholder="Manufacturer # / SKU" autocapitalize="characters">
<button class="rc-save" id="rcSave">Save</button>
</div>
<button class="rc-again" id="rcAgain">Take Another</button>
</div>
</div>
<div id="permWrap">
<h1>Camera access needed</h1>
<p id="permMsg">Allow camera access to start photographing samples.</p>
<button id="permRetry">Try Again</button>
</div>
<script>
(function(){
'use strict';
// ── DOM ──────────────────────────────────────────────────────────────────
var $ = function(id){ return document.getElementById(id); };
var camEl = $('cam'), shotEl = $('shot'), flashEl = $('flash');
var gearBtn = $('gear'), drawer = $('drawer');
var shutterWrap = $('shutterWrap'), shutterBtn = $('shutter');
var procWrap = $('procWrap'), procMsg = $('procMsg'), procTime = $('procTime');
var resultWrap = $('resultWrap'), rcard = $('rcard'), rcIcon = $('rcIcon'), rcTitle = $('rcTitle'),
rcSku = $('rcSku'), rcDetail = $('rcDetail'), rcDwUni = $('rcDwUni'), rcTime = $('rcTime'), rcAgain = $('rcAgain');
var rcManual = $('rcManual'), mVendor = $('mVendor'), mMfr = $('mMfr'), rcSave = $('rcSave');
var permWrap = $('permWrap'), permMsg = $('permMsg'), permRetry = $('permRetry');
var vendorOverrideSel = $('vendorOverride');
var swAuto = $('swAuto'), swTiming = $('swTiming');
// ── state ────────────────────────────────────────────────────────────────
var STATE = 'camera';
// pre-shot adjustments (all 11) — Minimal keeps its own Auto-create switch, so no second one here
var panel = AdjustPanel.create({ key:'dwSimpleTune', showAutoSave:false });
panel.attachPreview(camEl); // camera | processing | result
var stream = null, facing = 'environment';
var AUTO_CREATE = true, SHOW_TIMING = false;
var VENDORS = []; // warmed speculatively, used only for the manual fallback
var capturedDataUrl = null;
var lastExtractFields = null;
// ── SPEED: warm the vendor registry in the background the moment the page loads.
// This is pure overlapped/speculative work — it costs nothing on the critical capture
// path (nothing waits on it) and means that IF extraction fails to resolve a vendor,
// the manual-fallback dropdown is already populated with zero added latency instead of
// firing a fresh GET at that moment. ──────────────────────────────────────────────────
function warmVendors(){
fetch('/api/vendors-registry').then(function(r){ return r.json(); }).then(function(d){
VENDORS = (d && d.vendors) || [];
var fill = function(sel, withAuto){
var html = withAuto ? '<option value="">Auto-detect from label</option>' : '<option value="">Pick vendor…</option>';
html += VENDORS.map(function(v){
var name = v.vendor || v.real_vendor || '';
return name ? '<option value="'+esc(name)+'">'+esc(name)+'</option>' : '';
}).join('');
sel.innerHTML = html;
};
fill(vendorOverrideSel, true);
fill(mVendor, false);
}).catch(function(){ /* non-fatal — manual fallback still works via free-text mfr */ });
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
// ── camera ───────────────────────────────────────────────────────────────
function startCamera(){
permWrap.classList.remove('on');
if (stream) { stream.getTracks().forEach(function(t){ t.stop(); }); stream = null; }
var constraints = { audio:false, video:{ facingMode:{ ideal:facing }, width:{ideal:4096}, height:{ideal:3072} } };
navigator.mediaDevices.getUserMedia(constraints).then(function(s){
stream = s; camEl.srcObject = s; camEl.classList.toggle('mirror', facing==='user');
}).catch(function(e){
// fall back to ANY camera before giving up entirely
navigator.mediaDevices.getUserMedia({ audio:false, video:true }).then(function(s){
stream = s; camEl.srcObject = s;
}).catch(function(e2){
permMsg.textContent = (e2 && e2.name==='NotAllowedError')
? 'Camera permission was denied. Enable it in Settings and try again.'
: 'Could not start the camera: ' + (e2 && e2.message || 'unknown error');
permWrap.classList.add('on');
});
});
}
permRetry.addEventListener('click', startCamera);
// ── gear drawer ──────────────────────────────────────────────────────────
gearBtn.addEventListener('click', function(){ drawer.classList.toggle('open'); });
document.addEventListener('click', function(e){
if (drawer.classList.contains('open') && !drawer.contains(e.target) && e.target!==gearBtn) drawer.classList.remove('open');
});
$('btnFlip').addEventListener('click', function(){ facing = facing==='environment' ? 'user' : 'environment'; startCamera(); });
swAuto.addEventListener('click', function(){ AUTO_CREATE = !AUTO_CREATE; swAuto.classList.toggle('on', AUTO_CREATE); });
swTiming.addEventListener('click', function(){ SHOW_TIMING = !SHOW_TIMING; swTiming.classList.toggle('on', SHOW_TIMING);
procTime.hidden = !SHOW_TIMING; rcTime.hidden = !SHOW_TIMING; });
// ── state transitions ────────────────────────────────────────────────────
function toCamera(){
STATE = 'camera';
shotEl.classList.remove('on');
shutterWrap.style.display = ''; shutterBtn.hidden = false; shutterBtn.disabled = false;
procWrap.classList.remove('on');
resultWrap.classList.remove('on');
rcManual.classList.remove('on');
capturedDataUrl = null; lastExtractFields = null;
}
function toProcessing(msg){
STATE = 'processing';
shutterBtn.hidden = true;
procMsg.textContent = msg || 'Reading label…';
procWrap.classList.add('on');
}
function toResult(){
STATE = 'result';
procWrap.classList.remove('on');
resultWrap.classList.add('on');
}
// ── capture: SPEED — the frame is drawn + shown INSTANTLY (pure client-side canvas work,
// no network dependency), so the user sees "shot taken" with zero perceived latency. The
// live <video> stream is left running underneath (not stopped/renegotiated), so "Take
// Another" needs no getUserMedia round trip either — just hide the frozen frame. ────────
var shotCanvas = document.createElement('canvas');
function captureFrame(){
// 2000px max edge (same as Pro Booth), with the pre-shot adjustments baked in
return panel.bake(camEl, 2000, { mirror: facing === 'user' });
}
var tStart = 0;
function fmtMs(ms){ return ms < 1000 ? Math.round(ms)+'ms' : (ms/1000).toFixed(1)+'s'; }
shutterBtn.addEventListener('click', function(){
if (STATE !== 'camera') return;
tStart = performance.now();
flashEl.classList.remove('on'); void flashEl.offsetWidth; flashEl.classList.add('on');
capturedDataUrl = captureFrame();
shotEl.src = capturedDataUrl;
shotEl.classList.add('on'); // instant visual feedback — no network wait
toProcessing('Reading label…');
runPipeline(capturedDataUrl);
});
// Full-res: the native Camera app's photo is the product image; a 1600px copy feeds the label read.
$('btnFull').addEventListener('click', function(){
if (STATE !== 'camera') return;
NativePhoto.pick().then(function(p){
if (!p) { return; }
return panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(function(full){
if (STATE !== 'camera') return;
tStart = performance.now();
capturedDataUrl = full;
shotEl.src = capturedDataUrl;
shotEl.classList.add('on');
toProcessing('Reading label…');
runPipeline(capturedDataUrl, p.small);
});
});
});
// ── SPEED: the extract → create-item pipeline. /api/extract must return the vendor + mfr#
// before /api/create-item can be called (create-item hard-requires both), so — with the two
// endpoints as they exist today — these two calls cannot be parallelized from the client;
// extract's ~2.5–3s Gemini-vision round trip is the dominant, unavoidable cost of the FIRST
// exchange. What IS eliminated here vs. the full power-tool flow: the separate dry-run
// /api/create-item PREVIEW call (~290ms measured) that a review-UI would need — this page has
// no review step, so on a successful read it goes straight from extract's response into the
// REAL (committing) create-item call, cutting 3 sequential awaited round trips down to 2. ──
function runPipeline(dataUrl, extractUrl){
fetch('/api/extract', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ dataUrl: extractUrl || dataUrl }) })
.then(function(r){ return r.json(); })
.then(function(r){
var f = (r && r.fields) || {};
lastExtractFields = f;
var vendor = vendorOverrideSel.value || r.vendor_matched || '';
var mfr = cleanField(f.mfr_sku);
// auto-create only for a registry vendor (picked, or matched by the server) — an unmatched
// label vendor would mint a PROV- sku, so it goes to the confirm screen instead
var vendorOk = !!vendorOverrideSel.value || r.vendor_registered === true;
if (vendor && mfr && AUTO_CREATE && vendorOk) {
procMsg.textContent = 'Creating ' + vendor + ' item…';
createItem(dataUrl, vendor, mfr, f);
} else if (vendor && mfr && !AUTO_CREATE && vendorOk) {
showResult(true, { preview:true, vendor:vendor, mfr:mfr, fields:f });
} else {
showNeedsInfo(f, dataUrl, r && r.ok === false ? r.err : null);
}
})
.catch(function(e){
showResult(false, { err: 'Could not read the label — ' + (e && e.message || 'network error') });
});
}
function createItem(dataUrl, vendor, mfr, fields){
var payload = {
mfr: mfr, vendor: vendor, name: cleanField(fields.pattern_name), color: fields.color || '', material: fields.material || '',
collection: fields.collection || '', width: fields.width || '', roll_length: fields.roll_length || '',
repeat: fields.repeat || '', pattern_match: fields.pattern_match || '', substrate: fields.substrate || '',
how_sold: fields.how_sold || '', price: fields.price || '', price_code: fields.price_code || '',
photos: [dataUrl], back_present: false, commit: true
};
fetch('/api/create-item', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
.then(function(r){ return r.json(); })
.then(function(r){
if (r.duplicate) { showResult(false, { err: r.err, duplicate:true, mfr:mfr }); return; }
if (!r.ok) { showResult(false, { err: r.err || 'Create failed', mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); return; }
showResult(true, { dw_sku: r.dw_sku, title: r.title, vendor:vendor, dw_unified: r.dw_unified });
})
.catch(function(e){ showResult(false, { err: 'Network error creating item: ' + (e && e.message || ''), mfr:mfr, vendor:vendor, fields:fields, dataUrl:dataUrl }); });
}
function showNeedsInfo(fields, dataUrl, readErr){
var elapsed = performance.now() - tStart;
toResult();
rcard.className = 'rcard err';
rcIcon.textContent = '🏷';
rcTitle.textContent = "Couldn't auto-read the SKU";
rcSku.textContent = '';
rcDetail.textContent = (readErr ? 'Label read failed (' + readErr + '). ' : '') + 'Pick the vendor and type the mfr# to save this item.';
rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'read: ' + fmtMs(elapsed);
rcManual.classList.add('on');
mMfr.value = cleanField(fields.mfr_sku);
rcSave.onclick = function(){
var v = mVendor.value.trim(), m = mMfr.value.trim();
mVendor.classList.toggle('need', !v); mMfr.classList.toggle('need', !m);
if (!v || !m) return;
rcSave.disabled = true; rcSave.textContent = 'Saving…';
createItem(dataUrl, v, m, fields || {});
};
}
// dw_unified is a SEPARATE write from the Shopify draft (server.js createNewItem stages into
// new_items_staging); the create-item response can be ok:true with dw_unified failed, so this
// is shown independently rather than folded into the main ok/err branch.
function setDwUni(dwu){
if (!dwu) { rcDwUni.hidden = true; return; }
rcDwUni.hidden = false;
if (dwu.committed) { rcDwUni.className = 'rc-dwuni ok'; rcDwUni.textContent = '✓ saved to dw_unified'; }
else { rcDwUni.className = 'rc-dwuni warn'; rcDwUni.textContent = '⚠ dw_unified write failed — Shopify draft still created'; }
}
function showResult(ok, info){
var elapsed = performance.now() - tStart;
toResult();
rcManual.classList.remove('on');
rcSave.disabled = false; rcSave.textContent = 'Save';
rcTime.hidden = !SHOW_TIMING; rcTime.textContent = 'total: ' + fmtMs(elapsed);
procTime.hidden = !SHOW_TIMING;
if (ok && info.dw_sku) {
rcard.className = 'rcard ok';
rcIcon.textContent = '✓';
rcTitle.textContent = info.title || info.dw_sku;
rcSku.textContent = 'DW# ' + info.dw_sku + (info.vendor ? ' · ' + info.vendor : '');
rcDetail.textContent = 'Draft created — not live yet.';
setDwUni(info.dw_unified);
} else if (ok && info.preview) {
rcDwUni.hidden = true;
rcard.className = 'rcard ok';
rcIcon.textContent = '✓';
rcTitle.textContent = 'Read OK (preview mode)';
rcSku.textContent = info.mfr + ' · ' + info.vendor;
rcDetail.textContent = 'Auto-create is off — nothing was saved.';
} else {
rcDwUni.hidden = true;
rcard.className = 'rcard err';
rcIcon.textContent = '✗';
rcTitle.textContent = info.duplicate ? 'Already in the catalog' : "Couldn't save";
rcSku.textContent = info.mfr || '';
rcDetail.textContent = info.err || 'Unknown error.';
}
}
rcAgain.addEventListener('click', toCamera);
// ── boot ─────────────────────────────────────────────────────────────────
warmVendors();
startCamera();
})();
</script>
</body>
</html>