← back to Dw Photo Capture

public/simple-wizard.html

609 lines

<!doctype html>
<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>
<title>DW Sample Capture — Guided</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<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 Capture">
<link rel="apple-touch-icon" href="/icon-180.png">
<link rel="icon" href="/icon-192.png">
<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>
  /* ============================================================================================
     GUIDED WIZARD — TK-12162 concept-guided-wizard
     One focused job at a time. Numbered steps, one primary action per screen, large touch targets
     (min 64px), large text (18px+ body / 24px+ headings), high contrast. "TSA PreCheck kiosk"
     clarity, not "pro camera app" density. Built for a first-time / less-tech-savvy showroom guest.
     ============================================================================================ */
  :root{
    --bg:#0b1220; --panel:#101a2c; --panel2:#16233a; --line:#26374f;
    --fg:#f4f8fb; --dim:#9fb2c8; --accent:#22c3a6; --accent-d:#159a83;
    --warn:#e0a53a; --danger:#e0563a; --radius:22px;
    --safe-top:env(safe-area-inset-top,0px); --safe-bottom:env(safe-area-inset-bottom,0px);
  }
  *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
  html,body{height:100%;}
  body{
    margin:0; background:var(--bg); color:var(--fg);
    font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,Helvetica,Arial,sans-serif;
    font-size:18px; line-height:1.4; overscroll-behavior:none; user-select:none;
    display:flex; flex-direction:column; height:100dvh; width:100vw; overflow:hidden;
  }
  h1,h2,h3{margin:0; font-weight:800; letter-spacing:-0.01em;}
  /* ---- progress rail (always visible, top) ---- */
  #progress{
    display:flex; align-items:center; justify-content:center; gap:10px;
    padding:calc(14px + var(--safe-top)) 16px 14px;
    background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; z-index:20;
  }
  .step-dot{
    display:flex; align-items:center; gap:8px; opacity:.45; transition:opacity .25s;
  }
  .step-dot.active{opacity:1;}
  .step-dot .num{
    width:34px; height:34px; border-radius:50%; background:var(--panel2); border:2px solid var(--line);
    display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:var(--dim);
    transition:all .25s;
  }
  .step-dot.active .num{ background:var(--accent); border-color:var(--accent); color:#04231d; }
  .step-dot.done .num{ background:var(--accent-d); border-color:var(--accent-d); color:#fff; }
  .step-dot .lbl{ font-size:13px; font-weight:700; color:var(--dim); display:none; }
  .step-dot.active .lbl{ color:var(--fg); }
  @media (min-width:700px){ .step-dot .lbl{ display:inline; } }
  .step-line{ width:22px; height:2px; background:var(--line); flex:0 0 auto; }
  @media (min-width:700px){ .step-line{ width:40px; } }

  /* ---- screens ---- */
  #stage{ position:relative; flex:1 1 auto; min-height:0; }
  .screen{
    position:absolute; inset:0; display:none; flex-direction:column;
    padding:24px 20px calc(24px + var(--safe-bottom)); text-align:center;
  }
  .screen.on{ display:flex; }
  .screen h2{ font-size:26px; margin-bottom:8px; }
  .screen .sub{ font-size:18px; color:var(--dim); margin-bottom:18px; max-width:560px; align-self:center; }

  /* ---- camera stage ---- */
  .cam-wrap{
    position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
    background:#000; margin:0 auto 18px; width:100%; max-width:680px;
  }
  #video{ width:100%; height:100%; object-fit:cover; display:block; background:#000; }
  #snapCanvas{ display:none; }
  .frame-guide{
    position:absolute; inset:8%; border-radius:18px; pointer-events:none;
    box-shadow:0 0 0 2000px rgba(0,0,0,.38);
  }
  .frame-guide .corner{ position:absolute; width:38px; height:38px; border:5px solid var(--accent); }
  .frame-guide .tl{ top:-3px; left:-3px; border-right:none; border-bottom:none; border-top-left-radius:14px; }
  .frame-guide .tr{ top:-3px; right:-3px; border-left:none; border-bottom:none; border-top-right-radius:14px; }
  .frame-guide .bl{ bottom:-3px; left:-3px; border-right:none; border-top:none; border-bottom-left-radius:14px; }
  .frame-guide .br{ bottom:-3px; right:-3px; border-left:none; border-top:none; border-bottom-right-radius:14px; }
  .cam-hint{
    position:absolute; left:0; right:0; bottom:14px; text-align:center;
    font-size:16px; font-weight:700; color:#fff; text-shadow:0 1px 6px rgba(0,0,0,.7);
  }
  .countdown-num{
    position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
    font-size:140px; font-weight:900; color:#fff; text-shadow:0 4px 24px rgba(0,0,0,.6);
    opacity:0; transform:scale(.7); transition:none;
  }
  .countdown-num.show{ animation:pop .78s ease-out; }
  @keyframes pop{ 0%{opacity:0; transform:scale(.5);} 25%{opacity:1; transform:scale(1.08);} 70%{opacity:1; transform:scale(1);} 100%{opacity:0; transform:scale(.9);} }
  .flash{ position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
  .flash.go{ animation:flashfade .35s ease-out; }
  @keyframes flashfade{ 0%{opacity:.9;} 100%{opacity:0;} }

  /* ---- review photo ---- */
  .review-wrap{
    position:relative; flex:1 1 auto; min-height:0; border-radius:var(--radius); overflow:hidden;
    background:#000; margin:0 auto 18px; width:100%; max-width:680px;
    display:flex; align-items:center; justify-content:center;
  }
  #reviewImg{ width:100%; height:100%; object-fit:contain; display:block; }
  .analyzing-pill{
    position:absolute; top:14px; left:50%; transform:translateX(-50%);
    background:rgba(11,18,32,.82); border:1px solid var(--line); border-radius:999px;
    padding:8px 16px; font-size:14px; font-weight:700; color:var(--accent);
    display:flex; align-items:center; gap:8px; opacity:0; transition:opacity .2s;
  }
  .analyzing-pill.show{ opacity:1; }
  .dot-spin{ width:9px; height:9px; border-radius:50%; background:var(--accent); animation:blink 1s infinite ease-in-out; }
  @keyframes blink{ 0%,100%{opacity:.25;} 50%{opacity:1;} }

  /* ---- buttons ---- */
  .actions{ display:flex; gap:14px; justify-content:center; flex-wrap:wrap; flex:0 0 auto; }
  .actions.stack{ flex-direction:column; align-items:stretch; max-width:520px; margin:0 auto; width:100%; }
  button{
    font-family:inherit; border:none; border-radius:18px; cursor:pointer;
    font-size:20px; font-weight:800; padding:22px 30px; min-height:70px; min-width:180px;
    display:flex; align-items:center; justify-content:center; gap:10px;
    transition:transform .08s ease, filter .15s ease; -webkit-user-select:none;
  }
  button:active{ transform:scale(.97); }
  button:disabled{ opacity:.5; cursor:default; }
  .btn-primary{ background:var(--accent); color:#04231d; box-shadow:0 8px 24px rgba(34,195,166,.28); }
  .btn-primary:active{ filter:brightness(.95); }
  .btn-secondary{ background:var(--panel2); color:var(--fg); border:2px solid var(--line); }
  .btn-danger{ background:transparent; color:var(--dim); border:2px solid var(--line); font-size:17px; padding:16px 24px; min-height:56px; }
  .btn-big{ font-size:24px; padding:28px 40px; min-height:84px; width:100%; max-width:420px; align-self:center; }

  /* ---- done screen ---- */
  .checkmark{
    width:120px; height:120px; border-radius:50%; background:var(--accent); margin:0 auto 22px;
    display:flex; align-items:center; justify-content:center; box-shadow:0 10px 30px rgba(34,195,166,.35);
  }
  .checkmark svg{ width:62px; height:62px; }
  .done-card{
    background:var(--panel2); border:1px solid var(--line); border-radius:18px; padding:20px 22px;
    max-width:480px; margin:0 auto 22px; text-align:left; align-self:center; width:100%;
  }
  .done-card .row{ display:flex; justify-content:space-between; gap:14px; padding:8px 0; font-size:17px; }
  .done-card .row + .row{ border-top:1px solid var(--line); }
  .done-card .k{ color:var(--dim); font-weight:600; }
  .done-card .v{ font-weight:800; text-align:right; }

  /* ---- error / camera denied ---- */
  .err-box{ background:rgba(224,86,58,.12); border:1px solid var(--danger); border-radius:16px; padding:18px 20px; max-width:520px; margin:0 auto 18px; align-self:center; }

  /* ---- perf debug HUD (hidden unless ?debug=1) ---- */
  #hud{
    position:fixed; right:8px; bottom:calc(8px + var(--safe-bottom)); z-index:999;
    background:rgba(0,0,0,.82); color:#7CFC9C; font:11px/1.5 ui-monospace,Menlo,monospace;
    padding:8px 10px; border-radius:10px; max-width:280px; display:none; white-space:pre;
    pointer-events:none; /* debug overlay must never steal taps from the real UI beneath it */
  }
  #hud.show{ display:block; }
</style>
</head>
<body>

  <div id="progress">
    <div class="step-dot" data-step="1"><div class="num">1</div><div class="lbl">Position</div></div>
    <div class="step-line"></div>
    <div class="step-dot" data-step="2"><div class="num">2</div><div class="lbl">Capture</div></div>
    <div class="step-line"></div>
    <div class="step-dot" data-step="3"><div class="num">3</div><div class="lbl">Review</div></div>
    <div class="step-line"></div>
    <div class="step-dot" data-step="4"><div class="num">4</div><div class="lbl">Done</div></div>
  </div>

  <div id="stage">

    <!-- STEP 1 — POSITION -->
    <section class="screen" id="screen-1">
      <h2>Position the sample</h2>
      <div class="sub">Lay the sample flat and fill the frame. Good light helps.</div>
      <div class="cam-wrap">
        <video id="video" playsinline muted autoplay></video>
        <div class="frame-guide">
          <div class="corner tl"></div><div class="corner tr"></div>
          <div class="corner bl"></div><div class="corner br"></div>
        </div>
        <div class="cam-hint" id="camHint">Starting camera…</div>
      </div>
      <div class="actions">
        <button class="btn-primary btn-big" id="btnReady" disabled>I'm Ready →</button>
        <button class="btn-secondary" id="btnNative">📷 Full-res photo</button>
      </div>
    </section>

    <!-- STEP 2 — CAPTURE -->
    <section class="screen" id="screen-2">
      <h2>Hold still…</h2>
      <div class="sub">Capturing in a moment.</div>
      <div class="cam-wrap" id="captureWrap">
        <!-- video2 shares the same live stream, cloned in via JS -->
        <video id="video2" playsinline muted autoplay></video>
        <div class="frame-guide">
          <div class="corner tl"></div><div class="corner tr"></div>
          <div class="corner bl"></div><div class="corner br"></div>
        </div>
        <div class="countdown-num" id="countdownNum"></div>
        <div class="flash" id="flashEl"></div>
      </div>
      <canvas id="snapCanvas"></canvas>
    </section>

    <!-- STEP 3 — REVIEW -->
    <section class="screen" id="screen-3">
      <h2>How does it look?</h2>
      <div class="sub">Make sure the sample is clear and in focus.</div>
      <div class="review-wrap">
        <img id="reviewImg" alt="Captured sample photo">
        <div class="analyzing-pill" id="analyzingPill"><span class="dot-spin"></span> Reading label…</div>
      </div>
      <div class="actions">
        <button class="btn-secondary" id="btnRetake">↺ Retake</button>
        <button class="btn-primary" id="btnUse">✓ Use This Photo</button>
      </div>
    </section>

    <!-- STEP 4 — DONE -->
    <section class="screen" id="screen-4">
      <div style="flex:1 1 auto; display:flex; flex-direction:column; justify-content:center;">
        <div class="checkmark" id="doneIcon">
          <svg viewBox="0 0 24 24" fill="none"><path d="M4 12.5L9.5 18L20 6" stroke="#04231d" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </div>
        <h2 id="doneTitle">Sample Captured!</h2>
        <div class="sub" id="doneSub">Nice work — this sample is on its way into the catalog.</div>
        <div class="done-card" id="doneCard" style="display:none"></div>
        <div class="err-box" id="errCard" style="display:none"></div>
      </div>
      <div class="actions">
        <button class="btn-primary btn-big" id="btnAgain">➕ Add Another Sample</button>
      </div>
    </section>

  </div>

  <div id="hud"></div>

<script>
(function(){
  'use strict';
  const $ = id => document.getElementById(id);
  const DEBUG = /[?&]debug=1/.test(location.search);
  if (DEBUG) $('hud').classList.add('show');

  // ---------------------------------------------------------------------------------------------
  // TIMING LEDGER — every measured instant, for the speed writeup. Logged to console always;
  // rendered in the corner HUD only with ?debug=1 so the guest-facing screen stays clean.
  // ---------------------------------------------------------------------------------------------
  const T = {};
  function mark(k){ T[k] = performance.now(); renderHud(); return T[k]; }
  function ms(a,b){ return (T[b]!=null && T[a]!=null) ? Math.round(T[b]-T[a]) : null; }
  function renderHud(){
    if(!DEBUG) return;
    const lines = [
      'STEP TIMING (ms)',
      't_position_enter → t_capture_tap : ' + (ms('positionEnter','captureTap') ?? '—'),
      't_capture_tap → t_frame_grabbed  : ' + (ms('captureTap','frameGrabbed') ?? '—'),
      't_frame_grabbed → extract_fired  : ' + (ms('frameGrabbed','extractFired') ?? '—'),
      'extract in-flight (spec.)        : ' + (ms('extractFired','extractResolved') ?? '(pending)'),
      't_confirm_tap → extract_ready    : ' + (ms('confirmTap','extractResolved') ?? '0 (already done)'),
      't_confirm_tap → create_fired     : ' + (ms('confirmTap','createFired') ?? '—'),
      'create-item round trip           : ' + (ms('createFired','createResolved') ?? '(pending)'),
      '── TOTAL confirm → done ──        : ' + (ms('confirmTap','doneShown') ?? '—'),
    ];
    $('hud').textContent = lines.join('\n');
  }

  // ---------------------------------------------------------------------------------------------
  // STATE
  // ---------------------------------------------------------------------------------------------
  let stream = null;
  let capturedDataUrl = null;
  let extractPromise = null;     // speculative — fired the instant the frame is grabbed, step 2→3
  let extractResult = null;
  let currentStep = 1;

  function goStep(n){
    currentStep = n;
    document.querySelectorAll('.screen').forEach(s => s.classList.remove('on'));
    $('screen-' + n).classList.add('on');
    document.querySelectorAll('.step-dot').forEach(d => {
      const step = +d.dataset.step;
      d.classList.toggle('active', step === n);
      d.classList.toggle('done', step < n);
    });
  }

  // ---------------------------------------------------------------------------------------------
  // CAMERA — bounded acquire with a timeout race (a hung getUserMedia/play() must never freeze the
  // wizard on a first-time guest's device). Mirrors the robustness pattern already proven in
  // public/index.html's two-shot live cam (TK-12124/TK-12127), trimmed to what this flow needs.
  // ---------------------------------------------------------------------------------------------
  async function acquireCamera(){
    if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
      throw new Error('This device/browser can\'t access the camera. Try Safari on iPhone/iPad or Chrome on Android.');
    }
    const constraints = [
      { video:{ facingMode:{ ideal:'environment' }, width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
      { video:{ facingMode:{ ideal:'environment' } }, audio:false },
      { video:true, audio:false }
    ];
    let lastErr;
    for(const c of constraints){
      try{
        const timeout = new Promise((_,rej) => setTimeout(() => rej(new Error('timeout')), 8000));
        const s = await Promise.race([navigator.mediaDevices.getUserMedia(c), timeout]);
        return s;
      }catch(e){ lastErr = e; }
    }
    throw lastErr || new Error('camera unavailable');
  }

  async function startCamera(){
    $('camHint').textContent = 'Starting camera…';
    try{
      stream = await acquireCamera();
      const v1 = $('video'); v1.srcObject = stream;
      await v1.play().catch(()=>{});
      $('camHint').textContent = 'Fill the frame with the sample';
      $('btnReady').disabled = false;

      // ---- JEV-STYLE PREFETCH / WARM-UP -----------------------------------------------------
      // This is the "position" step's dwell time — the guest is adjusting the sample, which is
      // exactly the idle window JEV spends on speculative work instead of waiting for the next
      // user action. We fire a cheap same-origin GET now so the keep-alive connection this page
      // will reuse for the real POSTs (/api/extract, /api/create-item) is already established —
      // no fresh TCP/TLS handshake sitting in the critical path after the guest taps "Use".
      fetch('/healthz', { keepalive:true }).catch(()=>{});
      mark('positionEnter');
    }catch(e){
      $('camHint').textContent = '';
      showCameraError(e.message || String(e));
    }
  }

  function showCameraError(msg){
    const s = $('screen-1');
    if($('camErrBox')) return;
    const box = document.createElement('div');
    box.id = 'camErrBox'; box.className = 'err-box';
    box.innerHTML = '<b>Camera not available</b><br><span style="color:var(--dim)">' + msg.replace(/</g,'&lt;') + '</span>';
    s.insertBefore(box, s.querySelector('.actions'));
  }

  // ---------------------------------------------------------------------------------------------
  // STEP 1 → 2 : "I'm Ready"
  // ---------------------------------------------------------------------------------------------
  // Pre-shot adjustments (all 11): live on both camera views, baked into whichever photo is taken.
  const panel = AdjustPanel.create({ key:'dwSimpleTune', toggleParent: document.querySelector('#screen-1 .actions'), toggleClass:'btn-secondary' });
  panel.attachPreview($('video')); panel.attachPreview($('video2'));

  // Full-res: the phone's own Camera app (real sensor photo). Works even if the live camera failed.
  $('btnNative').addEventListener('click', () => {
    NativePhoto.pick().then(p => {
      if (!p) return;
      mark('captureTap');
      panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(full => grabFrame({ full, small: p.small }));
    });
  });

  $('btnReady').addEventListener('click', () => {
    mark('captureTap');
    // hand the SAME live stream to the capture screen's video element — no re-acquire, no flicker.
    const v2 = $('video2'); v2.srcObject = stream; v2.play().catch(()=>{});
    goStep(2);
    runCountdown();
  });

  // ---------------------------------------------------------------------------------------------
  // STEP 2 — countdown then auto-capture (confirm tap already happened; countdown just holds the
  // sample steady for a sharp shot — combines "clear confirm tap" + "countdown" from the brief).
  // ---------------------------------------------------------------------------------------------
  function runCountdown(){
    const el = $('countdownNum');
    const seq = ['3','2','1','📸'];
    let i = 0;
    (function tick(){
      el.textContent = seq[i];
      el.classList.remove('show'); void el.offsetWidth; el.classList.add('show');
      if(seq[i] === '📸'){
        setTimeout(() => { grabFrame(); }, 260);
        return;
      }
      i++;
      setTimeout(tick, 700);
    })();
  }

  let lastNative = false;   // retake after a native photo returns to step 1, not the live countdown
  function grabFrame(native){
    $('flashEl').classList.add('go');
    lastNative = !!native;
    if (native) {
      capturedDataUrl = native.full;
    } else {
    const v = $('video2');
    const canvas = $('snapCanvas');
    // downscale to a sane max edge — keeps the base64 payload small so the (already-fast) round
    // trips to /api/extract and /api/create-item aren't padded by an unnecessarily huge JPEG.
    const MAXW = 2000;   // same max edge as Pro Booth
    const vw = v.videoWidth || 1280, vh = v.videoHeight || 960;
    const scale = Math.min(1, MAXW / Math.max(vw, vh));
    canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
    capturedDataUrl = panel.bake(v, MAXW);   // pre-shot adjustments baked in
    }
    mark('frameGrabbed');

    $('reviewImg').src = capturedDataUrl;
    goStep(3);

    // ---- SPECULATIVE EXTRACTION (the core JEV-style optimization for this concept) -----------
    // Fire /api/extract THE INSTANT we have pixels — while the guest is still looking at the
    // review screen deciding whether to keep the shot — instead of waiting for the "Use This
    // Photo" tap. A person spends ~1-3s glancing at a photo before confirming; Gemini Vision OCR
    // typically resolves in a similar window. By the time they tap "Use", extraction is usually
    // already done, so only ONE round trip (/api/create-item) remains on the critical path
    // instead of two SEQUENTIAL ones. This mirrors Jev's "one request per decision, no idle
    // network gaps" — we just move the decision point earlier because we can predict it.
    const shotRef = capturedDataUrl;
    $('analyzingPill').classList.add('show');
    mark('extractFired');
    extractPromise = fetch('/api/extract', {
      method:'POST', headers:{ 'Content-Type':'application/json' },
      body: JSON.stringify({ dataUrl: (native && native.small) || capturedDataUrl })
    }).then(r => r.json()).then(j => {
      mark('extractResolved');
      extractResult = j;
      $('analyzingPill').classList.remove('show');
      // auto-save: label read gave vendor + mfr# -> confirm for the guest (same path as the Use tap)
      const jf = (j && j.fields) || {};
      if (panel.autoSave() && capturedDataUrl === shotRef && currentStep === 3 && j && j.ok !== false && j.vendor_registered === true
          && (j.vendor_matched || cleanField(jf.vendor)) && cleanField(jf.mfr_sku) && !$('btnUse').disabled) {
        setTimeout(() => { if (currentStep === 3 && capturedDataUrl === shotRef) $('btnUse').click(); }, 0);
      }
      return j;
    }).catch(e => {
      mark('extractResolved');
      extractResult = { ok:false, err: e.message };
      $('analyzingPill').classList.remove('show');
      return extractResult;
    });
  }

  // ---------------------------------------------------------------------------------------------
  // STEP 3 — Retake / Use This Photo
  // ---------------------------------------------------------------------------------------------
  $('btnRetake').addEventListener('click', () => {
    extractPromise = null; extractResult = null; capturedDataUrl = null;
    if (lastNative) { goStep(1); return; }
    goStep(2);
    runCountdown();
  });

  $('btnUse').addEventListener('click', async () => {
    mark('confirmTap');
    $('btnUse').disabled = true; $('btnRetake').disabled = true;
    try{
      await finishCapture();
    }catch(e){
      // Any unexpected throw must still land the guest on a screen with a way forward, never a
      // frozen review step with both buttons disabled.
      console.error('[guided-wizard] finishCapture failed', e);
      showDone({ ok:false, title: 'Photo saved', sub: 'The catalog step needs a hand from staff for this one (' + ((e && e.message) || 'unexpected error') + ').' });
    }
  });

  // ---------------------------------------------------------------------------------------------
  // STEP 3 → 4 : await the (likely-already-resolved) speculative extract, then the ONE remaining
  // round trip — /api/create-item — using the exact contract public/index.html's "add new item"
  // flow uses (mfr + vendor required server-side; front photo satisfies the photo gate; mfr from
  // OCR satisfies the identity gate when there's no separate back-label shot).
  // ---------------------------------------------------------------------------------------------
  async function finishCapture(){
    // Await the speculative extract IF it's still in flight (a fast/impatient tap can beat the
    // network back) — this is the only place the wizard ever waits on it; most of the time it's
    // already resolved from the review-screen dwell and this await returns on the same tick.
    if(extractPromise){ extractResult = await extractPromise; }
    const fields = (extractResult && extractResult.fields) || {};
    const vendor = extractResult && (extractResult.vendor_matched || fields.vendor) || '';
    const mfr = cleanField(fields.mfr_sku);

    if(!mfr || !vendor){
      // Honest graceful degradation — the label didn't read clearly enough to auto-identify.
      // A guided kiosk never shows a raw error; it explains plainly and hands off to staff.
      showDone({
        ok:false,
        title: 'Photo saved',
        sub: "We couldn't automatically read the label on this one — a team member will finish adding it.",
      });
      return;
    }

    mark('createFired');
    let r;
    try{
      r = await fetch('/api/create-item', {
        method:'POST', headers:{ 'Content-Type':'application/json' },
        body: JSON.stringify({
          dataUrl: capturedDataUrl,
          photos: [capturedDataUrl],
          mfr, vendor, vid: '',
          name: fields.pattern_name || '', color: fields.color || '',
          material: fields.material || 'Wallcovering',
          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 || '',
          id_source: 'guided-wizard-ocr', commit: true
        })
      }).then(res => res.json());
    }catch(e){
      r = { ok:false, err: e.message };
    }
    mark('createResolved');

    if(r && r.ok){
      showDone({ ok:true, dw_sku: r.dw_sku, vendor, dw_unified: r.dw_unified, title: 'Sample Captured!', sub: 'This sample is now a draft in the catalog.' });
    } else if(r && r.duplicate){
      showDone({ ok:true, dw_sku: (r.preview && r.preview.dw_sku) || '', vendor, dw_unified: r.dw_unified, title: 'Already in the catalog', sub: 'This exact sample is already on file — nothing more to do here.' });
    } else {
      showDone({ ok:false, title: 'Photo saved', sub: 'The catalog step needs a hand from staff for this one (' + ((r && r.err) || 'unknown issue') + ').' });
    }
  }

  function showDone(info){
    mark('doneShown');
    $('doneTitle').textContent = info.title;
    $('doneSub').textContent = info.sub;
    $('doneIcon').style.background = info.ok ? 'var(--accent)' : 'var(--warn)';
    if(info.ok && info.dw_sku){
      $('doneCard').style.display = 'block';
      // dw_unified is a SEPARATE write from the Shopify draft (server.js createNewItem stages
      // into new_items_staging) — ok:true here only means the Shopify draft succeeded, so this
      // row is shown independently rather than assumed from the overall ok.
      var dwuRow = info.dw_unified
        ? '<div class="row"><span class="k">dw_unified</span><span class="v" style="color:' +
          (info.dw_unified.committed ? 'var(--accent)' : 'var(--warn)') + '">' +
          (info.dw_unified.committed ? '✓ saved' : '⚠ failed') + '</span></div>'
        : '';
      $('doneCard').innerHTML =
        '<div class="row"><span class="k">DW SKU</span><span class="v">' + esc(info.dw_sku) + '</span></div>' +
        '<div class="row"><span class="k">Vendor</span><span class="v">' + esc(info.vendor || '—') + '</span></div>' +
        dwuRow;
      $('errCard').style.display = 'none';
    } else {
      $('doneCard').style.display = 'none';
    }
    goStep(4);
    if(DEBUG) console.log('[guided-wizard] timing ledger', JSON.parse(JSON.stringify(T)));
  }

  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }

  // ---------------------------------------------------------------------------------------------
  // "Add Another Sample" — full reset back to step 1, camera re-acquired fresh.
  // ---------------------------------------------------------------------------------------------
  $('btnAgain').addEventListener('click', () => {
    capturedDataUrl = null; extractPromise = null; extractResult = null;
    $('btnUse').disabled = false; $('btnRetake').disabled = false;
    for(const k of Object.keys(T)) delete T[k];
    goStep(1);
    if(!stream || !stream.active){ startCamera(); }
    else {
      $('video').srcObject = stream;
      mark('positionEnter');
    }
  });

  // boot
  goStep(1);
  startCamera();
})();
</script>
</body>
</html>