[object Object]

← back to Dw Photo Capture

feat: sample-request sticker popup + scan cost throttle

7f00ab46cab93e41dc090930d831c2fb75865ab8 · 2026-07-06 17:16:32 -0700 · Steve Abrams

- #sampleModal popup (reuses .hidden modal pattern): shows identified item + image + client (who
  ordered the sample) + client/vendor dates; Print SKU / Print address buttons → /api/print-sticker.
  Auto-fires from applyScan on a resolved scan (best id: barcode > matched sku > mfr code).
- Cost throttle: scanTick slows 600ms→1300ms when the engine is cloud-only (no macvision), and
  idle-pauses after 60s with tap-to-resume — caps Gemini spend on a forgotten-open scanner.

Files touched

Diff

commit 7f00ab46cab93e41dc090930d831c2fb75865ab8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 17:16:32 2026 -0700

    feat: sample-request sticker popup + scan cost throttle
    
    - #sampleModal popup (reuses .hidden modal pattern): shows identified item + image + client (who
      ordered the sample) + client/vendor dates; Print SKU / Print address buttons → /api/print-sticker.
      Auto-fires from applyScan on a resolved scan (best id: barcode > matched sku > mfr code).
    - Cost throttle: scanTick slows 600ms→1300ms when the engine is cloud-only (no macvision), and
      idle-pauses after 60s with tap-to-resume — caps Gemini spend on a forgotten-open scanner.
---
 public/index.html | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 91 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index 794fcac..5ab8cf8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -128,6 +128,24 @@
   .editor[hidden]{display:none}
   .ed-stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:12px 12px 0;overflow:hidden}
   #edcanvas{max-width:100%;max-height:100%;border-radius:12px;box-shadow:0 8px 40px rgba(0,0,0,.6);background:#000}
+  /* sample-request sticker popup */
+  .samp{position:fixed;inset:0;z-index:130;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:18px}
+  .samp[hidden]{display:none}
+  .samp-card{position:relative;width:100%;max-width:420px;background:#16140f;border:1px solid var(--line);border-radius:18px;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
+  .samp-x{position:absolute;top:12px;right:12px;background:none;border:none;color:var(--ink);font-size:20px;cursor:pointer;opacity:.7}
+  .samp-head{display:flex;gap:14px;align-items:center;margin-bottom:14px}
+  #sampImg{width:72px;height:72px;object-fit:cover;border-radius:12px;flex-shrink:0;background:#000}
+  .samp-pat{font-size:18px;font-weight:700;line-height:1.2}
+  .samp-meta{font-size:13px;color:#b7ad98;margin-top:3px}
+  .samp-client{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px 0;margin-bottom:14px}
+  .samp-row{display:flex;justify-content:space-between;gap:12px;padding:4px 0;font-size:14px}
+  .samp-row span{color:#9a917d}
+  .samp-row b{font-weight:600;text-align:right}
+  .samp-btns{display:flex;flex-direction:column;gap:10px}
+  .samp-print{padding:14px;border-radius:12px;border:none;font-size:16px;font-weight:700;cursor:pointer;background:#2b7a3f;color:#fff}
+  .samp-print.samp-addr{background:#2d5b86}
+  .samp-print:disabled{opacity:.5;cursor:default}
+  .samp-note{font-size:12px;color:#9a917d;margin-top:10px;text-align:center;min-height:14px}
   .ed-panel{flex:0 0 auto;background:var(--card);border-top:1px solid var(--line);padding:12px 14px max(14px,env(safe-area-inset-bottom));max-height:54vh;overflow-y:auto}
   .ed-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
   .ed-head .t{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.05em;text-transform:uppercase;color:var(--gold)}
@@ -283,6 +301,30 @@
 </div>
   </div>
 </div>
+
+<!-- sample-request sticker popup: scan → who ordered this sample → print SKU / address sticker -->
+<div class="samp" id="sampleModal" hidden>
+  <div class="samp-card">
+    <button class="samp-x" id="sampClose">✕</button>
+    <div class="samp-head">
+      <img id="sampImg" alt="" hidden>
+      <div class="samp-id">
+        <div class="samp-pat" id="sampPat">—</div>
+        <div class="samp-meta" id="sampMeta"></div>
+      </div>
+    </div>
+    <div class="samp-client">
+      <div class="samp-row"><span>Client ordered</span><b id="sampClient">—</b></div>
+      <div class="samp-row"><span>Client date</span><b id="sampOrdered">—</b></div>
+      <div class="samp-row"><span>Vendor ordered</span><b id="sampVendorDate">—</b></div>
+    </div>
+    <div class="samp-btns">
+      <button class="samp-print" id="sampPrintSku">🎫 Print SKU sticker</button>
+      <button class="samp-print samp-addr" id="sampPrintAddr">✉️ Print address sticker</button>
+    </div>
+    <div class="samp-note" id="sampNote"></div>
+  </div>
+</div>
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -978,7 +1020,8 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
 }));
 // 🔢 Scan a printed mfr#/SKU with the camera → OCR (server Vision) → jump to that product.
 /* ===== LIVE scanner: stream the camera, OCR each frame, LOCK IN when a SKU repeats ===== */
-let scanStream=null,scanTimer=null,scanBusy=false,_lastTop=null,_lockHits=0,_frontHits=0;
+let scanStream=null,scanTimer=null,scanBusy=false,_lastTop=null,_lockHits=0,_frontHits=0,_scanStart=0,_scanSlow=false;
+const SCAN_FAST=600, SCAN_CLOUD=1300, SCAN_IDLE_MS=60000;   // cloud OCR costs $ per frame → slow it + idle-pause
 // shared camera choice (back/front) — drives BOTH the live scanner and every "Add Photo" capture input
 let camFacing=LS('camFacing')||'environment';
 function applyCamCapture(){   // point all <input capture> pickers at the chosen lens
@@ -1066,11 +1109,13 @@ async function openLiveScan(){
   unlockAudio();                              // we're inside the user's tap → unlock audio for the lock-in cue
   try{ await startScanStream(); }
   catch(e){ toast('Camera blocked — using snap'); return $('#scanInput').click(); }
-  _lastTop=null;_lockHits=0;_frontHits=0; resetScanFields(); $('#scanlive').classList.remove('locked'); $('#scanStatus').textContent='Point at the printed SKU…';
+  _lastTop=null;_lockHits=0;_frontHits=0;_scanStart=Date.now();_scanSlow=false; resetScanFields(); $('#scanlive').classList.remove('locked'); $('#scanStatus').textContent='Point at the printed SKU…';
   $('#scanFlip').textContent=(camFacing==='user'?'🔄 Back camera':'🔄 Front camera');
   $('#scanlive').hidden=false; document.body.style.overflow='hidden';
-  scanTimer=setInterval(scanTick,600);
+  scanTimer=setInterval(scanTick,SCAN_FAST);
 }
+// tap the paused overlay to resume scanning (idle-pause caps cloud OCR cost)
+function resumeScan(){ if(!$('#scanlive').hidden && !scanTimer){ _scanStart=Date.now(); _lastTop=null;_lockHits=0;_frontHits=0; $('#scanStatus').textContent='Point at the printed SKU…'; scanTimer=setInterval(scanTick,_scanSlow?SCAN_CLOUD:SCAN_FAST); } }
 async function flipCam(){   // toggle back<->front, persist, re-acquire live + re-point capture inputs
   camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture(); syncCamBtn();
   $('#scanFlip').textContent=(camFacing==='user'?'🔄 Back camera':'🔄 Front camera');
@@ -1118,6 +1163,9 @@ async function scanTick(){
     cRoi.getContext('2d').drawImage(v, roiX,roiY,roiW,roiH, 0,0,cRoi.width,cRoi.height);
     const d=await ocrSmart(cRoi.toDataURL('image/jpeg',0.72));   // honed ROI; front-cam mirror-retry built in
     if(d&&d.fields) updateScanFields(d.fields);   // light SKU#/Model#/Name/Color chips green as they're read
+    // cost throttle: slow ticks on a cloud (paid) engine, and idle-pause to cap spend on a forgotten scanner
+    if(d&&d.available&&!d.available.includes('macvision')&&!_scanSlow){ _scanSlow=true; if(scanTimer){clearInterval(scanTimer);scanTimer=setInterval(scanTick,SCAN_CLOUD);} }
+    if(_scanStart&&(Date.now()-_scanStart)>SCAN_IDLE_MS&&!_lockHits&&!_frontHits){ if(scanTimer){clearInterval(scanTimer);scanTimer=null;} $('#scanStatus').textContent='⏸ Paused to save cost — tap to resume'; scanBusy=false; return; }
     if(d&&d.top){
       _frontHits=0;   // a real code appeared → not the pattern face; cancel any front streak
       if(d.top===_lastTop) _lockHits++; else {_lastTop=d.top;_lockHits=1;}
@@ -1210,7 +1258,47 @@ async function applyScan(d, imgDataUrl){
   $('#q').value=hit.q;
   if(hit.via==='any'){ chipTo('any'); toast('✓ '+hit.q+' — '+hit.n+' on TWIL'); doLookup(); }
   else { chipTo('shop'); toast('✓ '+hit.q+' — '+hit.n+' in Shopify'); doShopSearch(); }
+  // sample-request: who ordered this sample? (best id: barcode > matched sku > mfr code) → sticker popup
+  checkSampleRequest((d&&d.fields&&d.fields.barcode)||hit.q, (d&&d.fields&&d.fields.model)||null);
+}
+
+// ── Sample-request sticker popup: scan → who ordered this sample → print SKU/address sticker (FileMaker) ──
+let _sampReq=null;
+function openSampleModal(data){
+  _sampReq=data;
+  const id=data.identity||{}, item=data.item||{}, cl=data.client||{}, dt=data.dates||{};
+  const img=$('#sampImg');
+  if(id.image){ img.src=id.image; img.hidden=false; } else { img.hidden=true; img.removeAttribute('src'); }
+  $('#sampPat').textContent=item.mfr_pattern||id.pattern||id.mfr_sku||item.combo_sku||'Sample';
+  $('#sampMeta').textContent=[id.vendor||item.vid, item.combo_sku].filter(Boolean).join(' · ');
+  $('#sampClient').textContent=cl.name||'(no client on record)';
+  $('#sampOrdered').textContent=dt.client_ordered||'—';
+  $('#sampVendorDate').textContent=dt.vendor_ordered||'—';
+  $('#sampNote').textContent=''; $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false;
+  $('#sampleModal').hidden=false; document.body.style.overflow='hidden';
+}
+function closeSampleModal(){ $('#sampleModal').hidden=true; document.body.style.overflow=''; _sampReq=null; }
+async function sampPrint(type){
+  if(!_sampReq||!_sampReq.recordId) return;
+  $('#sampPrintSku').disabled=true; $('#sampPrintAddr').disabled=true; $('#sampNote').textContent='Sending to FileMaker…';
+  try{
+    const r=await(await fetch('/api/print-sticker',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({recordId:_sampReq.recordId,sticker:type})})).json();
+    if(r.ok){ $('#sampNote').textContent=r.queued?('✓ '+type+' sticker sent to FileMaker to print'):('(preview) would flag '+type+' — FM_WRITE=1 to go live'); toast('🎫 '+type+' → FileMaker'); }
+    else { $('#sampNote').textContent='✗ '+(r.err||'failed'); $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false; }
+  }catch(e){ $('#sampNote').textContent='✗ '+e.message; $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false; }
+}
+async function checkSampleRequest(code,mfr){
+  if(!code&&!mfr)return;
+  try{ const q=new URLSearchParams(); if(code)q.set('code',code); if(mfr)q.set('mfr',mfr);
+    const r=await(await fetch('/api/sample-request?'+q.toString())).json();
+    if(r&&r.found) openSampleModal(r);
+  }catch(e){}
 }
+$('#sampClose').addEventListener('click',closeSampleModal);
+$('#sampPrintSku').addEventListener('click',()=>sampPrint('sku'));
+$('#sampPrintAddr').addEventListener('click',()=>sampPrint('address'));
+$('#scanvideo').addEventListener('click',resumeScan);   // tap the paused overlay to resume scanning
+$('#scanStatus').addEventListener('click',resumeScan);
 $('#scanCancel').addEventListener('click',closeLiveScan);
 $('#scanFlip').addEventListener('click',flipCam);
 // 🏷 Identify the brand by its LOGO + typesetting via the local VLM (qwen2.5vl). Slower

← 31dbd12 feat: FileMaker sample-request lookup + print-sticker handof  ·  back to Dw Photo Capture  ·  docs: FileMaker sticker setup — flag field + OnTimer poller 1f9cc3b →