← 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>