← back to Dw Photo Capture
public/index.html
2026 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1">
<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 SKU Photos">
<meta name="theme-color" content="#0f0e0c">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/icon-180.png">
<link rel="manifest" href="/manifest.webmanifest">
<title>Mobile DW SKU Photos</title>
<style>
:root{ --cols: 1; --bg:#0f0e0c; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25;
--gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;-webkit-text-size-adjust:100%}
header{position:sticky;top:0;z-index:10;background:rgba(15,14,12,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:max(10px,env(safe-area-inset-top)) 14px 10px}
h1{font:600 17px/1.1 "SF Pro Display",sans-serif;letter-spacing:.04em;margin:0 0 8px;text-transform:uppercase}
h1 span{color:var(--gold)}
h1 .ver{font:600 10px/1 ui-monospace,Menlo,monospace;text-decoration:none;color:#1b1407;background:var(--green);border-radius:99px;padding:3px 7px;letter-spacing:.04em;vertical-align:middle;text-transform:none;font-style:normal;margin-left:6px}
.bar{height:8px;border-radius:99px;background:#2a261f;overflow:hidden;margin:6px 0}
.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),#e7c878);width:0;transition:width .3s}
.meta{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
.controls{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
input[type=search],select{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 11px;font-size:15px}
input[type=search]{flex:1;min-width:140px}
.scanbtn{position:relative;overflow:hidden;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:9px 12px;font-size:14px;font-weight:600;white-space:nowrap;cursor:pointer}
.scanbtn input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
.chips{display:flex;gap:6px}
.chip{padding:7px 13px;border-radius:99px;border:1px solid var(--line);background:#16140f;color:var(--muted);font-size:13px;cursor:pointer}
.chip.on{background:var(--gold);color:#1b1407;border-color:var(--gold);font-weight:600}
.recog{grid-column:1/-1;background:var(--card);border:1px solid var(--gold);border-radius:14px;padding:12px 14px;margin-bottom:4px}
.recog .rg-d{font-size:14px;color:var(--ink);margin-bottom:8px}
.recog .rg-d b{color:var(--gold)}
.recog .rg-tags{display:flex;flex-wrap:wrap;gap:6px}
.recog .rg-tags span{font-size:12px;padding:4px 9px;border-radius:99px;background:#16140f;border:1px solid var(--line);color:var(--muted)}
.recog .rg-tags span.sw{padding-left:7px;display:inline-flex;align-items:center;gap:5px}
.recog .rg-tags i{width:11px;height:11px;border-radius:99px;display:inline-block;border:1px solid rgba(255,255,255,.25)}
.recog .rg-code{color:var(--gold);font-weight:600;cursor:pointer}
.dens{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
input[type=range]{accent-color:var(--gold)}
main{padding:14px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;max-width:1200px;margin:0 auto}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
.card.done{border-color:var(--green)}
.thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
.thumb.tap{cursor:pointer} .thumb.tap:hover{filter:brightness(1.08)}
.badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
.fav-btn{position:absolute;top:6px;left:6px;width:32px;height:32px;border:none;border-radius:50%;background:rgba(8,7,6,.55);color:#f3efe7;font-size:18px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}
.fav-btn.on{color:var(--gold)}
.fav-btn:active{transform:scale(.88)}
.b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
/* Discontinued SKU → red card with live successor link */
.disco-card{grid-column:1/-1;background:#2a1512;border:1px solid #c0392b;border-left:5px solid #c0392b;border-radius:16px;padding:16px 18px;display:flex;flex-direction:column;gap:10px}
.disco-card .dhead{font:800 16px/1.3 "SF Pro Display",sans-serif;color:#e8776a;display:flex;align-items:center;gap:8px}
.disco-card .dsku{font:600 12px/1 ui-monospace,Menlo,monospace;color:#e8a79e;letter-spacing:.03em}
.disco-card .dstatus{font-size:10.5px;font-weight:700;letter-spacing:.05em;color:#fff;background:#c0392b;border-radius:99px;padding:2px 9px;text-transform:uppercase}
.disco-succ{background:#16140f;border:1px solid #3a5a44;border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}
.disco-succ .slabel{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#7fbf9a;font-weight:700}
.disco-succ .stitle{font:600 15px/1.3 "SF Pro Display",sans-serif;color:var(--ink)}
.disco-succ .ssku{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--green)}
.disco-succ a{color:var(--green);text-decoration:none;font-weight:700}
.disco-succ .go{align-self:flex-start;margin-top:4px;background:var(--green);color:#fff;border:none;border-radius:10px;padding:8px 15px;font-size:13.5px;font-weight:700;cursor:pointer;text-decoration:none;display:inline-block}
.disco-nosucc{font-size:13px;color:var(--muted)}
.b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
.b-new{background:#2a2350;color:#cbb8ff;box-shadow:0 0 0 1px #5a4aa0}
.body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:6px;flex:1}
.ttl{font:600 15px/1.25 "SF Pro Display",sans-serif}
.model{display:inline-flex;align-items:center;gap:6px;background:#22201a;border:1px solid var(--line);border-radius:8px;padding:5px 9px;align-self:flex-start;cursor:pointer}
.model small{font-size:9.5px;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}
.model b{font:800 16px/1 ui-monospace,Menlo,monospace;color:var(--ink);letter-spacing:.03em}
.sku{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold);letter-spacing:.02em}
.sub{font-size:11.5px;color:var(--muted);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.cols{display:flex;gap:5px;flex-wrap:wrap}
.colchip{font-size:10px;color:#bcd;background:#16201c;border:1px solid #2a3a33;border-radius:99px;padding:2px 7px;white-space:nowrap}
.price{color:var(--green);font-weight:700}
.price.no{color:var(--muted)}
.when{font-size:11px;color:var(--muted)}
/* "make this the only image" — destructive opt-in, only shown when an existing image would be wiped */
.onlyimg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);margin-top:2px;cursor:pointer;user-select:none}
.onlyimg input{accent-color:var(--red);width:15px;height:15px;flex:0 0 auto}
.onlyimg.armed{color:var(--red);font-weight:600}
.actions{display:flex;gap:8px;margin-top:auto;padding-top:6px}
.shoot{flex:1;position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border:none;border-radius:12px;padding:13px;font-weight:700;font-size:15px;text-align:center;cursor:pointer}
.shoot input{position:absolute;inset:0;opacity:0;font-size:200px;cursor:pointer}
.skip{background:#16140f;border:1px solid var(--line);color:var(--muted);border-radius:12px;padding:13px 14px;font-size:14px;cursor:pointer}
.enh{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 14px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
.gal{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 12px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
.card.done .shoot{background:#16140f;color:var(--gold);border:1px solid var(--gold)}
.empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:11px 18px;border-radius:12px;font-size:14px;z-index:50;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:1}
/* live SKU scanner */
.scanlive{position:fixed;inset:0;z-index:110;background:#000;display:flex;align-items:center;justify-content:center}
.scanlive[hidden]{display:none}
#scanvideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.scan-frame{position:relative;width:78%;max-width:520px;aspect-ratio:5/2;border:2px solid rgba(255,255,255,.85);border-radius:16px;box-shadow:0 0 0 4000px rgba(0,0,0,.45);overflow:hidden}
.scan-line{position:absolute;left:0;right:0;height:2px;background:var(--gold);box-shadow:0 0 10px var(--gold);animation:scan 1.6s ease-in-out infinite}
@keyframes scan{0%{top:8%}50%{top:90%}100%{top:8%}}
.scan-status{position:absolute;bottom:max(90px,22%);left:50%;transform:translateX(-50%);background:rgba(8,7,6,.8);color:#fff;font:700 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.04em;padding:11px 18px;border-radius:99px;border:1px solid rgba(255,255,255,.2)}
/* LOCKED: the SKU is confirmed — the whole frame turns red, then the scanner goes */
.scanlive.locked .scan-frame{border-color:var(--red);box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset;animation:lockpulse .42s ease}
.scanlive.locked .scan-line{display:none}
.scanlive.locked .scan-status{background:var(--red);border-color:#fff;color:#fff;box-shadow:0 0 18px rgba(192,86,63,.85)}
@keyframes lockpulse{0%{box-shadow:0 0 0 4000px rgba(0,0,0,.45),0 0 0 0 var(--red) inset}100%{box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset}}
.scan-fields{position:absolute;left:0;right:0;bottom:calc(84px + env(safe-area-inset-bottom,0px));display:flex;flex-wrap:wrap;gap:6px;justify-content:center;padding:0 12px;z-index:112;pointer-events:none}
.sf-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 9px;border-radius:999px;background:rgba(20,20,20,.68);color:#9aa0a6;border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(4px);max-width:46vw;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;transition:background .18s,color .18s,box-shadow .18s}
.sf-chip b{font-weight:700;opacity:.6;font-size:10px;letter-spacing:.03em;text-transform:uppercase}
.sf-chip i{font-style:normal;font-weight:600;max-width:34vw;overflow:hidden;text-overflow:ellipsis}
.sf-chip.on{background:rgba(34,150,70,.94);color:#fff;border-color:#7CFFB0;box-shadow:0 0 10px rgba(46,200,90,.6)}
.sf-chip.on b{opacity:.85}
.scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
.scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
.scan-logo{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 16px;font-size:15px;font-weight:600;cursor:pointer}
.scan-pat{position:absolute;top:max(20px,env(safe-area-inset-top));right:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:11px 15px;font-size:15px;font-weight:600;cursor:pointer}
/* photo gallery */
.gallery{position:fixed;inset:0;z-index:90;background:rgba(10,9,7,.98);display:flex;flex-direction:column}
.gallery[hidden]{display:none}
.gal-head{position:sticky;top:0;display:flex;align-items:center;gap:10px;padding:max(12px,env(safe-area-inset-top)) 14px 12px;border-bottom:1px solid var(--line);background:rgba(10,9,7,.98)}
#galAnswer{margin:10px 14px;padding:12px 14px;border-radius:12px;background:rgba(212,175,107,.12);border:1px solid var(--line);color:#f3efe7;font-size:15px;line-height:1.45}
#galAnswer .qq{color:var(--muted);font-size:13px;display:block;margin-bottom:5px}
.gal-x{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 13px;font-size:14px;cursor:pointer}
.gal-title{flex:1;min-width:0;line-height:1.2} .gal-title b{display:block;font:600 15px/1.2 "SF Pro Display",sans-serif} .gal-title span{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold)}
.gal-add{position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border-radius:10px;padding:9px 14px;font-weight:700;font-size:14px;cursor:pointer}
.gal-add input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
.gal-add-multi{background:#16140f;border:1px solid var(--gold);color:var(--gold)}
.gal-grid{flex:1;overflow-y:auto;padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;align-content:start}
.gtile{position:relative;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0c0b09;aspect-ratio:1}
.gtile img{width:100%;height:100%;object-fit:cover;display:block}
.gtile.feat{border-color:var(--gold);box-shadow:0 0 0 2px rgba(200,162,74,.4)}
.gfeat-badge{position:absolute;top:6px;left:6px;background:var(--gold);color:#1b1407;font:700 10px/1 sans-serif;padding:3px 7px;border-radius:99px}
.gtile-acts{position:absolute;bottom:0;left:0;right:0;display:flex;gap:1px}
.gtile-acts button{flex:1;border:none;padding:9px 4px;font-size:12px;font-weight:600;cursor:pointer;background:rgba(15,14,12,.92);color:var(--ink)}
.gtile-acts .g-feat{color:var(--gold)} .gtile-acts .g-del{color:var(--red)}
.gal-empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
/* ===== Photo editor (opens after capture, before upload) ===== */
.editor{position:fixed;inset:0;z-index:100;background:rgba(8,7,6,.97);display:flex;flex-direction:column}
.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}
.fb-tiles{display:flex;gap:12px;margin-bottom:14px}
.fb-tile{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;background:#0f0e0b;border:1px dashed var(--line);border-radius:14px;padding:12px 6px;color:#b7ad98;font-size:13px;cursor:pointer}
.fb-tile.set{border-style:solid;border-color:#2b7a3f;color:#fff}
.fb-tile.need{border-style:solid;border-color:#b8902f;color:#fff;box-shadow:0 0 0 2px rgba(184,144,47,.35);animation:micpulse 1.1s infinite}
#fbIncoming:empty{display:none}
.inc-hd{font-size:12px;color:#b7ad98;margin-bottom:7px}.inc-hd b{color:#e7ddc7}
.inc-strip{display:flex;gap:8px;overflow-x:auto;padding-bottom:6px;margin-bottom:12px;-webkit-overflow-scrolling:touch}
.inc-card{flex:none;width:104px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0f0e0b;cursor:pointer}
.inc-card.hit{border-color:#2bd06a;box-shadow:0 0 0 2px rgba(43,208,106,.4)}
.inc-thumb{width:104px;height:82px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:22px;color:#5a544a}
.inc-meta{padding:5px 6px}.inc-pat{font-size:11px;color:#e7ddc7;line-height:1.25;max-height:28px;overflow:hidden}
.inc-sub{font-size:10px;color:#9a917d;margin-top:2px}
.inc-recv{margin-top:5px;width:100%;padding:5px 4px;font-size:11px;font-weight:600;border:1px solid #2bd06a;background:rgba(43,208,106,.12);color:#2bd06a;border-radius:7px;cursor:pointer}
.inc-recv:hover{background:rgba(43,208,106,.22)}.inc-recv:disabled{opacity:.55;cursor:default}
.wp-d-card{margin-top:10px;border:1px solid var(--line);border-radius:12px;padding:11px 12px;background:#0f0e0b}
.wp-d-hd{font-size:12px;font-weight:600;color:#c8bfa8;margin-bottom:8px}.wp-d-empty{color:#7d7663;font-weight:400}
.wp-d-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:5px 14px}
.wp-d-row{display:flex;justify-content:space-between;gap:8px;font-size:12px;border-bottom:1px dotted #2a2721;padding-bottom:3px}
.wp-d-lbl{color:#8f887a}.wp-d-val{color:#eadfc6;font-weight:600;text-align:right}
.wp-d-desc{margin-top:8px;font-size:11px;color:#b3aa93;line-height:1.4}
.fb-read{margin-top:6px;font-size:11px;color:#8f887a;font-family:ui-monospace,Menlo,monospace;word-break:break-word}
.fb-search{display:flex;gap:7px;margin:10px 0 2px}
.fb-search input{flex:1;min-width:0;padding:9px 11px;font-size:14px;border:1px solid var(--line);border-radius:9px;background:#0f0e0b;color:#eadfc6}
.fb-search input::placeholder{color:#6f6857}
.fb-search button{flex:none;padding:9px 14px;font-size:13px;font-weight:600;border:1px solid var(--line);border-radius:9px;background:#1a1813;color:#c8bfa8;cursor:pointer}
.fb-search button:hover{background:#242017}
.wp-c-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}
.wp-c{border:1px solid var(--line);border-radius:10px;padding:8px 10px;background:#0f0e0b;cursor:pointer}
.wp-c:hover{border-color:#c8bfa8;background:#161410}
.wp-c-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline}
.wp-c-top b{font-size:13px;color:#eadfc6}.wp-c-sku{font-size:11px;color:#8f887a}
.wp-c-sub{font-size:11px;color:#9a917d;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fb-thumb{width:100%;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:30px}
#fbResult{margin-top:12px}
.fb-hit{display:flex;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:12px;margin-top:8px}
.fb-hit img{width:54px;height:54px;object-fit:cover;border-radius:8px;background:#000;flex-shrink:0}
.fb-conf{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;margin-left:6px}
.fb-conf.veryhigh,.fb-conf.high{background:#2b7a3f;color:#fff}.fb-conf.medium{background:#b8902f;color:#fff}.fb-conf.low{background:#7a3b2b;color:#fff}
.add-field{margin-bottom:10px;flex:1}.add-field label{display:block;font-size:11px;color:#9a917d;margin-bottom:3px}
.add-field input,.add-field select{width:100%;box-sizing:border-box;background:#0f0e0b;border:1px solid var(--line);border-radius:8px;padding:9px;color:var(--ink);font-size:15px}
.add-row{display:flex;gap:10px}
.add-field input.need,.add-field select.need{border-color:#b8902f;box-shadow:0 0 0 2px rgba(184,144,47,.35)}
.in-mic{display:flex;gap:6px;align-items:stretch}.in-mic input,.in-mic select{flex:1;min-width:0}
.mic-btn{flex:none;width:42px;border:1px solid var(--line);border-radius:8px;background:#151310;color:#e7ddc7;font-size:17px;cursor:pointer;line-height:1}
.mic-btn:active{transform:scale(.94)}.mic-btn.on{background:#7a2b2b;border-color:#b84a4a;color:#fff;animation:micpulse 1s infinite}
@keyframes micpulse{50%{background:#a53a3a}}
.add-voice{display:block;width:100%;box-sizing:border-box;margin:0 0 12px;padding:11px;border:1px solid var(--line);border-radius:10px;background:#151310;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
.add-voice:active{transform:scale(.99)}
.media-sub{font-size:12px;color:#b7ad98;margin-bottom:10px;min-height:0}
.media-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:10px}
.media-grid:empty{display:none}
.media-cell{position:relative;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;overflow:hidden;border:1px solid var(--line)}
.media-cell video{width:100%;height:100%;object-fit:cover}
.media-cell .badge{position:absolute;left:4px;bottom:4px;font-size:15px;filter:drop-shadow(0 1px 2px #000)}
.media-cell .rm{position:absolute;top:2px;right:2px;width:22px;height:22px;border:none;border-radius:50%;background:rgba(0,0,0,.65);color:#fff;font-size:14px;line-height:22px;cursor:pointer;padding:0}
.media-add{display:flex;gap:10px;margin-bottom:12px}
.media-btn{flex:1;padding:14px 6px;border:1px dashed var(--line);border-radius:12px;background:#0f0e0b;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
.media-btn:active{transform:scale(.98)}.media-btn:disabled{opacity:.4;cursor:not-allowed}
.media-btn span{opacity:.65;font-weight:400;font-size:12px}
.vid-cands{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.vid-chip{padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:#151310;color:#e7ddc7;font-size:12px;cursor:pointer}
.vid-chip:active{transform:scale(.96)}
#measureModal{position:fixed;inset:0;z-index:400;background:#000}
#measureModal[hidden]{display:none}
#mVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.mbox{position:absolute;border:2px solid;border-radius:6px;touch-action:none;min-width:34px;min-height:34px}
.mbox.mcard{border-color:#ff5a5a;box-shadow:0 0 0 9999px rgba(0,0,0,.02)}
.mbox.mgoods{border-color:#2bd06a}
.mlbl{position:absolute;top:-9px;left:6px;font-size:11px;background:#000;padding:0 5px;color:#fff}
.mcard .mlbl{color:#ff9a9a}.mgoods .mlbl{color:#7cf0a6}
.mrz{position:absolute;right:-13px;bottom:-13px;width:26px;height:26px;background:rgba(255,255,255,.9);border-radius:50%;touch-action:none;cursor:nwse-resize}
.mreadout{position:absolute;top:14px;left:0;right:0;text-align:center;color:#fff;font-size:17px;text-shadow:0 1px 4px #000;pointer-events:none}
.mreadout b{color:#ffe08a}
.mbar{position:absolute;bottom:22px;left:0;right:0;display:flex;gap:12px;justify-content:center}
.mbar button{padding:13px 20px;border:none;border-radius:999px;font-size:15px;font-weight:700;cursor:pointer}
#mUse{background:#2bd06a;color:#032}#mClose{background:#222;color:#fff}
.add-spec{display:flex;justify-content:space-between;gap:12px;font-size:12.5px;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.05)}
.add-spec span{color:#9a917d}.add-spec b{color:var(--ink);text-align:right}
/* onload chooser — three white pills on black */
#homeScreen{position:fixed;inset:0;z-index:200;background:#000;display:flex;align-items:center;justify-content:center}
#homeScreen[hidden]{display:none}
.home-inner{display:flex;flex-direction:column;gap:16px;align-items:center;width:100%;max-width:340px;padding:24px}
.home-title{color:#fff;font-size:14px;letter-spacing:.1em;text-transform:uppercase;opacity:.55;margin-bottom:16px}
.home-pill{width:100%;background:#fff;color:#000;border:none;border-radius:999px;padding:20px;font-size:19px;font-weight:700;cursor:pointer}
.home-pill:active{transform:scale(.98)}
.home-skip{margin-top:10px;background:none;border:none;color:#fff;opacity:.4;font-size:14px;cursor:pointer}
#homeBtn{background:none;border:none;color:var(--ink);opacity:.5;font-size:18px;cursor:pointer;float:right}
.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)}
.ed-head .s{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
.roll-toggle{margin-left:auto;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:7px 14px;font-size:13px;font-weight:600;cursor:pointer}
.roll-toggle.on{background:var(--gold);color:#1b1407;border-color:var(--gold)}
.ed-grid{display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:start}
.ed-wheel-wrap{display:flex;flex-direction:column;align-items:center;gap:6px}
.wheel-box{position:relative;width:128px;height:128px;touch-action:none}
#wheel{width:128px;height:128px;border-radius:50%;cursor:crosshair;box-shadow:inset 0 0 0 1px var(--line)}
.wheel-thumb{position:absolute;width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.5);transform:translate(-50%,-50%);left:50%;top:50%;pointer-events:none}
.wheel-lbl{font-size:10.5px;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;text-align:center}
.wheel-lbl b{display:block;color:var(--ink);font-size:11px;letter-spacing:.02em;text-transform:none;margin-top:2px}
.sliders{display:flex;flex-direction:column;gap:9px}
.sl{display:grid;grid-template-columns:74px 1fr 44px;align-items:center;gap:10px}
.sl label{font-size:12px;color:var(--muted)}
.sl input[type=range]{width:100%;accent-color:var(--gold)}
.sl .v{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--ink);text-align:right}
.ed-crop{background:#16140f;border:1px solid var(--gold);color:var(--gold)}
/* crop overlay */
.cropov{position:fixed;inset:0;z-index:120}
.cropov[hidden]{display:none}
.cropbox{position:fixed;border:1px solid #fff;box-shadow:0 0 0 4000px rgba(0,0,0,.55);cursor:move;touch-action:none;
background-image:linear-gradient(rgba(255,255,255,.25) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.25) 1px,transparent 1px);background-size:33.33% 33.33%}
.cropbox i{position:absolute;width:26px;height:26px;border:3px solid var(--gold);background:rgba(0,0,0,.25);box-sizing:border-box;touch-action:none}
.cropbox i[data-h=nw]{left:-3px;top:-3px;border-right:0;border-bottom:0;cursor:nwse-resize}
.cropbox i[data-h=ne]{right:-3px;top:-3px;border-left:0;border-bottom:0;cursor:nesw-resize}
.cropbox i[data-h=sw]{left:-3px;bottom:-3px;border-right:0;border-top:0;cursor:nesw-resize}
.cropbox i[data-h=se]{right:-3px;bottom:-3px;border-left:0;border-top:0;cursor:nwse-resize}
.crop-presets{position:fixed;left:50%;top:max(14px,env(safe-area-inset-top));transform:translateX(-50%);display:flex;gap:7px;z-index:121}
.crop-presets button{background:rgba(8,7,6,.75);border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:8px 13px;font-size:13px;font-weight:600;cursor:pointer}
.crop-presets button.on{background:var(--gold);color:#1b1407;border-color:var(--gold)}
.crop-bar{position:fixed;left:50%;bottom:max(24px,env(safe-area-inset-bottom));transform:translateX(-50%);display:flex;gap:12px;z-index:121}
.crop-bar button{border:none;border-radius:12px;padding:14px 26px;font-weight:700;font-size:15px;cursor:pointer}
#cropCancel{background:#16140f;border:1px solid var(--red);color:var(--red)}
#cropApply{background:var(--gold);color:#1b1407}
.ed-actions{display:flex;gap:10px;margin-top:14px}
.ed-actions button{flex:1;border:none;border-radius:12px;padding:14px;font-weight:700;font-size:15px;cursor:pointer}
.ed-reset{background:#16140f;border:1px solid var(--line);color:var(--muted)}
.ed-cancel{background:#16140f;border:1px solid var(--red);color:var(--red)}
.ed-use{background:var(--gold);color:#1b1407;flex:1.6}
</style>
</head>
<body>
<!-- onload chooser: three simple pills, white on black, centered -->
<div id="homeScreen">
<div class="home-inner">
<div class="home-title">DW Sample Station</div>
<button class="home-pill" data-act="add">Add New SKU</button>
<button class="home-pill" data-act="update">Update SKU</button>
<button class="home-pill" data-act="lookup">Look Up SKU</button>
<button class="home-pill" data-act="check">Check Sample In</button>
<button class="home-skip" data-act="skip">skip →</button>
</div>
</div>
<header>
<h1>Mobile DW SKU <span>Photos</span> <a href="/selfcheck" class="ver" title="tap for self-check">__VER__</a> <button id="homeBtn" title="home">⌂</button></h1>
<div class="bar"><i id="prog"></i></div>
<div class="meta"><span id="count">…</span><span id="remain"></span></div>
<div class="controls">
<input type="search" id="q" placeholder="🔎 Search name or model #…" autocapitalize="characters">
<button class="scanbtn" id="scanBtn" title="Scan a printed SKU/model #">🔢 Scan</button>
<button class="scanbtn" id="micBtn" title="Voice search — say a vendor, SKU, or what to look for">🎤</button>
<button class="scanbtn" id="camBtn" title="Choose camera (back / front)">📷 Back</button>
<button class="scanbtn" id="simBtn" title="Photograph a pattern/swatch → find this + similar across the whole catalog">🎨 Similar</button>
<button class="scanbtn" id="fbBtn" title="Identify by photos of the BACK label + FRONT pattern (best ID: code + visual)">🔎 ID F+B</button>
<button class="scanbtn" id="addBtn" title="Add a NEW item to the catalog + Shopify (draft)">➕ New item</button>
<input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
<input type="file" accept="image/*" capture="environment" id="simInput" hidden>
<input type="file" accept="image/*" capture="environment" id="fbBackInput" hidden>
<input type="file" accept="image/*" capture="environment" id="fbFrontInput" hidden>
<select id="sort">
<option value="ready">Priced first</option>
<option value="title">Title A→Z</option>
<option value="sku">SKU A→Z</option>
</select>
</div>
<div class="controls">
<div class="chips">
<div class="chip" data-f="need">Needs photo</div>
<div class="chip" data-f="live">🟢 Live</div>
<div class="chip" data-f="done">Done</div>
<div class="chip" data-f="new" id="chipNew">✨ New</div>
<div class="chip on" data-f="twil" id="chipTwil">🧵 All TWIL</div>
<div class="chip" data-f="all">All</div>
<div class="chip" data-f="any">🔎 Any SKU</div>
<div class="chip" data-f="shop">🌐 All Shopify</div>
<div class="chip" data-f="similar" id="chipSim">🎨 Similar</div>
<div class="chip" data-f="fav">⭐ Favorites</div>
<div class="chip" data-f="recent">🕘 Recent</div>
</div>
<div class="dens">Size <input type="range" id="dens" min="1" max="4" value="1"></div>
</div>
</header>
<main id="grid"></main>
<div class="toast" id="toast"></div>
<!-- ===== Live SKU scanner: camera auto-locks on a printed SKU, then looks it up ===== -->
<div class="scanlive" id="scanlive" hidden>
<video id="scanvideo" playsinline muted></video>
<div class="scan-frame"><div class="scan-line"></div></div>
<div class="scan-status" id="scanStatus">Point at the printed SKU…</div>
<div class="scan-fields" id="scanFields">
<span class="sf-chip" data-k="sku"><b>SKU#</b> <i>—</i></span>
<span class="sf-chip" data-k="model"><b>Model#</b> <i>—</i></span>
<span class="sf-chip" data-k="name"><b>Name</b> <i>—</i></span>
<span class="sf-chip" data-k="color"><b>Color</b> <i>—</i></span>
</div>
<button class="scan-flip" id="scanFlip">🔄 Flip camera</button>
<button class="scan-logo" id="scanLogo" title="Identify the brand by its logo / typeface (local AI)">🏷 Logo</button>
<button class="scan-pat" id="scanPat" title="Recognize the PATTERN → find this + similar across the whole catalog (local AI)">🎨 Pattern</button>
<button class="scan-cancel" id="scanCancel">✕ Cancel</button>
</div>
<!-- ===== Photo gallery (all photos for a SKU: keep / remove / feature / add) ===== -->
<div class="gallery" id="gallery" hidden>
<div class="gal-head">
<button class="gal-x" id="galClose">‹ Back</button>
<div class="gal-title"><b id="galName"></b><span id="galSku"></span></div>
<label class="gal-add gal-add-multi">➕ Multiple<input type="file" accept="image/*" multiple id="galAddMulti"></label>
<label class="gal-add">📷 Add<input type="file" accept="image/*" capture="environment" id="galAdd"></label>
<label class="gal-add gal-add-multi" title="Upload a product video">🎥 Video<input type="file" accept="video/*" capture="environment" id="galVid"></label>
<button class="gal-add" id="galAsk" title="Ask a question about this SKU (local AI)">🎤 Ask</button>
</div>
<div id="galAnswer" hidden></div>
<div class="gal-grid" id="galGrid"></div>
</div>
<!-- ===== Photo editor modal (review before upload) ===== -->
<div class="editor" id="editor" hidden>
<div class="ed-stage"><canvas id="edcanvas"></canvas></div>
<div class="ed-panel">
<div class="ed-head"><span class="t">Enhance</span><span class="s" id="ed-sku"></span><button class="roll-toggle" id="edRoll">🗞 Roll</button></div>
<div class="ed-grid">
<div class="ed-wheel-wrap">
<div class="wheel-box" id="wheelBox">
<canvas id="wheel" width="128" height="128"></canvas>
<div class="wheel-thumb" id="wheelThumb"></div>
</div>
<div class="wheel-lbl">Color Balance<b id="wheelVal">neutral</b></div>
</div>
<div class="sliders">
<div class="sl"><label>Brightness</label><input type="range" id="edB" min="0.4" max="1.6" step="0.01" value="1"><span class="v" id="edBv">0</span></div>
<div class="sl"><label>Contrast</label><input type="range" id="edC" min="0.5" max="1.6" step="0.01" value="1"><span class="v" id="edCv">0</span></div>
<div class="sl"><label>Saturation</label><input type="range" id="edS" min="0" max="2.2" step="0.01" value="1"><span class="v" id="edSv">0</span></div>
<div class="sl"><label>Hue</label><input type="range" id="edH" min="-180" max="180" step="1" value="0"><span class="v" id="edHv">0°</span></div>
<div class="sl" id="edCurlRow" style="display:none"><label>🗞 Curl</label><input type="range" id="edCurl" min="0" max="1" step="0.01" value="0.5"><span class="v" id="edCurlv">50%</span></div>
</div>
</div>
<div class="ed-actions">
<button class="ed-crop" id="edCrop">✂️ Crop</button>
<button class="ed-reset" id="edReset">Reset</button>
<button class="ed-cancel" id="edCancel">Cancel</button>
<button class="ed-use" id="edUse">✓ Use Photo</button>
</div>
<!-- crop overlay (Photoshop-style: drag handles, dim outside) -->
<div class="cropov" id="cropov" hidden>
<div class="crop-presets" id="cropPresets">
<button class="on" data-r="0">Free</button>
<button data-r="1">1:1</button>
<button data-r="1.3333">4:3</button>
<button data-r="0.75">3:4</button>
<button data-r="1.7778">16:9</button>
</div>
<div class="cropbox" id="cropbox"><i data-h="nw"></i><i data-h="ne"></i><i data-h="sw"></i><i data-h="se"></i></div>
<div class="crop-bar"><button id="cropCancel">✕ Cancel</button><button id="cropApply">✓ Apply Crop</button></div>
</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>
<!-- front + back identify: fuse back-label code + front-pattern visual match -->
<div class="samp" id="fbModal" hidden>
<div class="samp-card">
<button class="samp-x" id="fbClose">✕</button>
<div class="samp-pat" style="margin-bottom:4px">Identify a sample</div>
<div class="samp-meta" style="margin-bottom:12px">Photograph the BACK label, the FRONT pattern, or both — best ID uses both.</div>
<div id="fbIncoming"></div>
<div class="fb-tiles">
<button class="fb-tile" id="fbBackTile"><div class="fb-thumb" id="fbBackThumb">📄</div><span>Back · label</span></button>
<button class="fb-tile" id="fbFrontTile"><div class="fb-thumb" id="fbFrontThumb">🎨</div><span>Front · pattern</span></button>
</div>
<button class="samp-print" id="fbGo" disabled>🔎 Identify</button>
<div class="fb-search"><input id="fbSearchInput" type="search" inputmode="search" autocomplete="off" placeholder="or type any part of a SKU / description — e.g. 21750"><button type="button" id="fbSearchBtn">Search</button></div>
<div class="samp-note" id="fbNote"></div>
<div id="fbResult"></div>
</div>
</div>
<!-- add a NEW item to catalog + Shopify (draft), with vendor/vid dropdown -->
<div class="samp" id="addModal" hidden>
<div class="samp-card">
<button class="samp-x" id="addClose">✕</button>
<div class="samp-pat" id="addTitle" style="margin-bottom:4px">Add new item</div>
<div class="media-sub" id="addTarget"></div>
<div class="media-grid" id="addMedia"></div>
<div class="media-add">
<button class="media-btn" id="addPhotoBtn">📷 Photo <span id="cPhoto">0/10</span></button>
<button class="media-btn" id="addVideoBtn">🎥 Video <span id="cVideo">0/6</span></button>
</div>
<input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
<input type="file" accept="video/*" capture="environment" id="addVideoInput" hidden>
<button class="add-voice" id="addMeasure">📐 Measure real size (place a card by the goods)</button>
<button class="add-voice" id="addVoice">🎤 Voice-fill everything that's missing</button>
<div class="add-field"><label>Mfr # / SKU</label><div class="in-mic"><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"><button class="mic-btn" data-mic="addMfr" data-kind="sku" title="Speak the SKU / mfr number">🎤</button></div></div>
<div class="add-field"><label>Vendor</label><div class="in-mic"><select id="addVendor"><option value="">Loading…</option></select><button class="mic-btn" data-mic="addVendor" data-kind="vendor" title="Speak the vendor name">🎤</button></div></div>
<div class="add-row"><div class="add-field"><label>VID</label><div class="in-mic"><input id="addVid" placeholder="THI"><button class="mic-btn" data-mic="addVid" data-kind="text" title="Speak the VID">🎤</button></div></div><div class="add-field"><label>Color</label><div class="in-mic"><input id="addColor"><button class="mic-btn" data-mic="addColor" data-kind="text" title="Speak the color">🎤</button></div></div></div>
<div class="add-row"><div class="add-field"><label>Pattern name</label><div class="in-mic"><input id="addName"><button class="mic-btn" data-mic="addName" data-kind="text" title="Speak the pattern name">🎤</button></div></div><div class="add-field"><label>Price</label><div class="in-mic"><input id="addPrice" type="number" inputmode="decimal"><button class="mic-btn" data-mic="addPrice" data-kind="num" title="Speak the price">🎤</button></div></div></div>
<div id="addSpecs"></div>
<button class="samp-print" id="addPreview">👁 Preview</button>
<div class="samp-note" id="addNote"></div>
<div id="addResult"></div>
</div>
</div>
<!-- Live on-camera dimension measure: a credit card (3.375") in frame calibrates real inches -->
<div id="measureModal" hidden>
<video id="mVideo" autoplay playsinline muted></video>
<div class="mbox mcard" id="mCard"><span class="mlbl">💳 card</span><i class="mrz"></i></div>
<div class="mbox mgoods" id="mSample"><span class="mlbl">📦 goods</span><i class="mrz"></i></div>
<div class="mreadout" id="mReadout">Size 💳 to a credit card, 📦 to the goods</div>
<div class="mbar">
<button id="mSaveCal" hidden>✓ Save calibration</button>
<button id="mUse" hidden>✓ Use W×L</button>
<button id="mRecal" hidden>↺ Recalibrate</button>
<button id="mClose">✕ Close</button>
</div>
</div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
const LS=k=>localStorage.getItem('dwphoto_'+k), setLS=(k,v)=>localStorage.setItem('dwphoto_'+k,v);
// Density: set grid-template-columns DIRECTLY (older iOS Safari ignores var() as
// the count inside repeat() — writing the resolved repeat(N,1fr) works everywhere).
function applyDensity(n){
const c=Math.max(1,Math.min(4,parseInt(n,10)||1));
document.documentElement.style.setProperty('--cols',c);
$('#grid').style.gridTemplateColumns='repeat('+c+',1fr)';
}
// restore prefs
if(LS('dens')){$('#dens').value=LS('dens');applyDensity(LS('dens'));}
else applyDensity($('#dens').value);
if(LS('sort'))$('#sort').value=LS('sort');
if(LS('filter')){filter=LS('filter');document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.dataset.f===filter));}
function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),2200);}
function fmt(ts){if(!ts)return'';try{return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return''}}
async function load(){
const r=await fetch('/api/queue'); const d=await r.json();
ITEMS=d.items; render();
}
function priced(x){return x.price && parseFloat(x.price)>4.25;}
let LOOKUP=[], LOOKUP_INDEXED=0, _lkT=0, DISCO=null;
async function doLookup(){
const q=($('#q').value||'').trim();
DISCO=null;
if(!q){ LOOKUP=[]; render(); return; }
try{ const r=await fetch('/api/lookup?q='+encodeURIComponent(q)); const d=await r.json();
LOOKUP=(d.items||[]).map(it=>({...it, keep_images: !it.needs_create && !!it.image})); // preserve existing room/lifestyle shots on live SKUs
LOOKUP_INDEXED=d.indexed||0;
// No active TWIL hit? Before escalating to a store-wide search, check whether this is a
// DISCONTINUED / deleted SKU — if so, show the RED card pointing at its live successor.
if(!LOOKUP.length && q){
try{
const dr=await(await fetch('/api/discontinued?q='+encodeURIComponent(q))).json();
if(dr && dr.found && dr.discontinued){ DISCO=dr; render(); return; }
}catch(e){}
// otherwise auto-escalate to a store-wide search (mfr# + DW SKU across every vendor)
toast('Not in TWIL — searching all Shopify…');
document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on')); const sc=document.querySelector('.chip[data-f="shop"]'); if(sc)sc.classList.add('on');
filter='shop'; setLS('filter','shop'); $('#q').placeholder='🌐 Any Shopify SKU, name, vendor, or collection…';
return doShopSearch();
}
render();
}catch(e){ toast('Lookup failed'); }
}
function discoCardHtml(d){
const succ = d.successor;
const succUrl = succ && succ.handle ? ('https://www.designerwallcoverings.com/products/'+succ.handle) : '';
const succHtml = succ
? `<div class="disco-succ">
<span class="slabel">Now available as →</span>
<span class="stitle">${(succ.title||'').replace(/</g,'<')}</span>
<span class="ssku">${(succ.sku||'').replace(/</g,'<')}</span>
${succUrl?`<a class="go" href="${succUrl}" target="_blank" rel="noopener">View live product →</a>`:''}
</div>`
: `<div class="disco-nosucc">No live successor found — search the store for a comparable pattern.</div>`;
return `<div class="disco-card">
<div class="dhead">⛔ Discontinued <span class="dstatus">${(d.status||'').replace(/</g,'<')}</span></div>
<div>${(d.title||'').replace(/</g,'<')} <span class="dsku">${(d.sku||'').replace(/</g,'<')}</span></div>
${succHtml}
</div>`;
}
// The not-yet-in-Shopify sheet items — photograph one to create + go live.
let NEWITEMS=[];
async function loadNew(){
try{ const r=await fetch('/api/new'); const d=await r.json();
NEWITEMS=(d.items||[]).map(it=>({...it, keep_images:false}));
const ch=$('#chipNew'); if(ch) ch.textContent='✨ New ('+(d.total||0)+')';
if(filter==='new') render();
}catch(e){}
}
let TWILITEMS=[];
async function loadTwil(){
try{ const r=await fetch('/api/twil'); const d=await r.json();
TWILITEMS=d.items||[];
const ch=$('#chipTwil'); if(ch) ch.textContent='🧵 All TWIL ('+(d.total||0)+')';
if(filter==='twil') render();
}catch(e){}
}
// Favorites (starred) + Recents (auto-logged on upload) — server-stored so the
// same lists show on phone AND office. Card-shaped, render straight into the grid.
let FAVORITES=[], RECENTS=[];
async function loadFav(){ try{ const d=await(await fetch('/api/favorites')).json(); FAVORITES=d.items||[]; if(filter==='fav')render(); }catch(e){} }
async function loadRecent(){ try{ const d=await(await fetch('/api/recents')).json(); RECENTS=d.items||[]; if(filter==='recent')render(); }catch(e){} }
// the display fields the server caches so recents/favorites render without a Shopify lookup
const cardMeta=x=>({title:x.title||'',mfr:x.mfr||'',price:x.price||null,image:(x.image&&!/^\/photos\//.test(x.image))?x.image:null,product_id:x.product_id||null,status:x.status||''});
async function toggleFav(x,btn){
const on=!x.fav; x.fav=on;
if(btn){ btn.textContent=on?'★':'☆'; btn.classList.toggle('on',on); }
try{
await fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dw_sku:x.dw_sku,on,meta:cardMeta(x)})});
toast(on?('⭐ Favorited '+x.dw_sku):('Removed '+x.dw_sku));
loadFav(); if(filter==='fav')render(); // drop it from the Favorites view immediately on un-star
}catch(e){ x.fav=!on; if(btn){btn.textContent=x.fav?'★':'☆';btn.classList.toggle('on',x.fav);} toast('Favorite failed'); }
}
// Live store-wide search — covers EVERY Shopify SKU, not just Fentucci.
let SHOPSEARCH=[];
async function doShopSearch(){
const q=($('#q').value||'').trim();
if(!q){ SHOPSEARCH=[]; render(); return; }
$('#count').textContent='Searching all Shopify…';
try{ const r=await fetch('/api/shopify-search?q='+encodeURIComponent(q)); const d=await r.json();
SHOPSEARCH=d.items||[]; if(d.err) toast('Shopify: '+d.err); render();
}catch(e){ toast('Shopify search failed'); }
}
// 🎨 Recognize a PATTERN (the swatch itself) → this + similar across the whole unified DB.
let SIMILAR=[], RECOGNIZED=null, QSIG=null, VRANK='', _rerankSeq=0;
const _SIGCACHE=new Map();
// One reusable 8×8 canvas for every fingerprint — imgSig runs synchronously (draw→read→return),
// so there's no reentrancy and ~49 transient canvas allocations per recognize collapse to one.
const _SIGN=8, _sigCtx=Object.assign(document.createElement('canvas'),{width:_SIGN,height:_SIGN}).getContext('2d',{willReadFrequently:true});
// Perceptual "thumbnail fingerprint": average the image down to an 8×8 RGB grid (192 nums,
// 0-1). Two patterns that look alike — similar colors in similar places — land close together.
function imgSig(src){
try{ _sigCtx.drawImage(src,0,0,_SIGN,_SIGN); }catch(e){ return null; }
let d; try{ d=_sigCtx.getImageData(0,0,_SIGN,_SIGN).data; }catch(e){ return null; } // tainted canvas → null
const sig=new Float32Array(_SIGN*_SIGN*3);
for(let i=0;i<_SIGN*_SIGN;i++){ sig[i*3]=d[i*4]/255; sig[i*3+1]=d[i*4+1]/255; sig[i*3+2]=d[i*4+2]/255; }
return sig;
}
function sigDist(a,b){ if(!a||!b)return 1e9; let s=0; for(let i=0;i<a.length;i++){const e=a[i]-b[i]; s+=e*e;} return s; }
function sigFromDataUrl(dataUrl){ return new Promise(res=>{ const im=new Image(); im.onload=()=>res(imgSig(im)); im.onerror=()=>res(null); im.src=dataUrl; }); }
// Fingerprint a candidate's ACTUAL catalog image (tiny 72px, fetched same-origin through the
// server proxy so Shopify's no-CORS image doesn't taint the canvas). Cached per URL.
function candSig(url){
if(_SIGCACHE.has(url)) return Promise.resolve(_SIGCACHE.get(url));
return new Promise(res=>{
const small=url+(url.includes('?')?'&':'?')+'width=72';
const im=new Image(); im.crossOrigin='anonymous';
im.onload=()=>{ const s=imgSig(im); _SIGCACHE.set(url,s); res(s); };
im.onerror=()=>{ _SIGCACHE.set(url,null); res(null); };
im.src='/api/imgproxy?url='+encodeURIComponent(small);
});
}
const _vd=x=>(x._vdist==null?1e9:x._vdist);
// Re-rank the tag-prefiltered candidates by VISUAL distance to the photographed swatch —
// "sort by the actual pattern image." Runs in the background, repainting as results land.
async function visualRerank(){
if(!QSIG || !SIMILAR.length){ VRANK=''; return; }
const seq=++_rerankSeq, items=SIMILAR.filter(x=>x.image);
VRANK='running'; if(filter==='similar') render();
let idx=0, done=0;
async function worker(){
while(idx<items.length){
const x=items[idx++];
const s=await candSig(x.image);
if(seq!==_rerankSeq) return; // a newer recognize superseded us
x._vdist=sigDist(QSIG,s); done++;
if(done%8===0 && filter==='similar') render(); // progressive repaint
}
}
await Promise.all(Array.from({length:6},()=>worker())); // 6-way concurrency
if(seq!==_rerankSeq) return;
VRANK='done'; if(filter==='similar') render();
}
async function recognizeDataUrl(dataUrl){
toast('🎨 Recognizing pattern…'); $('#count').textContent='🎨 Reading pattern…';
QSIG=null; VRANK=''; const seq=++_rerankSeq;
const sigP=sigFromDataUrl(dataUrl); // fingerprint the photo in parallel with the VLM call
try{
const r=await fetch('/api/recognize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
const d=await r.json();
RECOGNIZED=d.recognized||null; SIMILAR=d.items||[];
collapsed=false; chipTo('similar'); $('#q').value=''; render();
QSIG=await sigP; // already resolved in the common (VLM-slow) case
if(QSIG && seq===_rerankSeq) visualRerank(); // sort by the actual pattern image
if(d.err && !SIMILAR.length){ toast('Recognize failed: '+d.err); }
else if(RECOGNIZED && RECOGNIZED.code){ toast('🏷 Code seen: '+RECOGNIZED.code+' · '+SIMILAR.length+' similar'); }
else { toast('🎨 '+SIMILAR.length+' similar pattern'+(SIMILAR.length===1?'':'s')+' found'); }
}catch(e){ toast('Recognize failed — is Ollama running?'); }
}
async function recognizePattern(file){
if(!file)return;
const dataUrl=await downscale(file,1024); // honors never-upscale (shrink only); 1024 keeps the VLM fast
return recognizeDataUrl(dataUrl);
}
// map a few common color NAMES → a hex chip so the recognized palette reads at a glance
const _CHEX={black:'#111',white:'#f4f1ea',cream:'#efe7d4',ivory:'#f3ecd8',beige:'#d9c9a8',taupe:'#b3a489',
gray:'#9a9a9a',grey:'#9a9a9a',charcoal:'#3a3a3a',silver:'#c7c7c7',gold:'#c8a24a',brass:'#b08d2f',
brown:'#6b4a2b',chocolate:'#3f2a1a',tan:'#c2a878',rust:'#9c4a23',terracotta:'#b5613c',
red:'#a3322a',burgundy:'#5e2230',pink:'#d98aa0',blush:'#e3c3c5',coral:'#e0745a',orange:'#cc6a2c',
yellow:'#d9b84a',mustard:'#b8902f',green:'#3f7a4a',olive:'#6b6a30',sage:'#9aa885',emerald:'#1f6b4a',
teal:'#2f7b7b',blue:'#345f8a',navy:'#1f2f4a','slate blue':'#5a6b86',sky:'#7fa6c9',
purple:'#6a4a82',lavender:'#b8a8cf',plum:'#5e3a55',mauve:'#9a7a86'};
function colorHex(name){const k=String(name||'').toLowerCase().trim();if(_CHEX[k])return _CHEX[k];
for(const c in _CHEX){if(k.includes(c))return _CHEX[c];}return null;}
function recogBanner(){
if(!RECOGNIZED)return'';
const r=RECOGNIZED, parts=[];
if(r.motif)parts.push(r.motif); if(r.style)parts.push(r.style); if(r.scale)parts.push(r.scale+' scale');
const desc=r.description||(parts.length?parts.join(' · '):'pattern');
const tags=[];
if(r.material)tags.push(`<span>🧵 ${r.material}</span>`);
if(r.motif)tags.push(`<span>${r.motif}</span>`);
if(r.style)tags.push(`<span>${r.style}</span>`);
(r.colors||[]).forEach(c=>{const h=colorHex(c);tags.push(h?`<span class="sw"><i style="background:${h}"></i>${c}</span>`:`<span>${c}</span>`);});
if(r.background)tags.push(`<span class="sw"><i style="background:${colorHex(r.background)||'#444'}"></i>bg: ${r.background}</span>`);
const codeLine=r.code?` <span class="rg-code" onclick="chipTo('shop');$('#q').value='${r.code}';doShopSearch();">· 🏷 ${r.code} ↗</span>`:'';
return `<div class="recog"><div class="rg-d">🎨 Recognized: <b>${desc}</b>${codeLine}</div><div class="rg-tags">${tags.join('')}</div></div>`;
}
function render(){
if(collapsed){
const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
$('#prog').style.width=(total?done/total*100:0)+'%';
$('#count').textContent=`${total} patterns · ${done} done`;
$('#remain').textContent='pick a filter to begin';
grid.innerHTML='<div class="empty">📸 Pick a filter, 🔎 search a SKU/collection, or tap 🔢 Scan to begin.'+(total?('<br><br>'+ (total-done) +' still need a photo.'):'')+'</div>';
return;
}
// A resolved discontinued/deleted SKU → red successor card (takes over the grid for the 'any' lookup filter).
if(DISCO && (filter==='any'||filter==='shop')){
$('#prog').style.width='0%';
$('#count').textContent='Discontinued SKU';
$('#remain').textContent='→ see live successor';
grid.innerHTML=discoCardHtml(DISCO);
return;
}
const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS
: filter==='twil' ? TWILITEMS : filter==='fav' ? FAVORITES : filter==='recent' ? RECENTS
: filter==='similar' ? SIMILAR : ITEMS;
const recog = filter==='similar'; // server returned a ranked similarity set
const liveSearch = filter==='any'||filter==='shop'; // server already filtered these
const curated = filter==='fav'||filter==='recent'; // server-stored lists, local q-filter only
let list=src.filter(x=>{
if(filter==='need'&&(x.done||x.skipped))return false;
if(filter==='live'&&!x.live)return false;
if(filter==='done'&&!x.done)return false;
if(!liveSearch&&!recog&&q&&!((x.title||'').toLowerCase().includes(q)||(x.dw_sku||'').toLowerCase().includes(q)||(x.mfr||'').toLowerCase().includes(q)))return false;
return true;
});
list.sort((a,b)=> sort==='title'?(a.title||'').localeCompare(b.title||'')
: sort==='sku'?(a.dw_sku||'').localeCompare(b.dw_sku||'')
: recog?(_vd(a)-_vd(b))||((b.score||0)-(a.score||0)) // visual (actual-image) distance, then tag score
: (priced(b)-priced(a))||(a.title||'').localeCompare(b.title||''));
if(liveSearch){
const typed=($('#q').value||'').trim();
$('#prog').style.width='0%';
$('#count').textContent = typed ? `${list.length} match${list.length===1?'':'es'}`
: (filter==='shop' ? 'Type any SKU, name, or vendor to search the whole store'
: 'Type a SKU, model #, or name to find any product');
$('#remain').textContent = filter==='shop' ? '🌐 all Shopify' : (LOOKUP_INDEXED?`${LOOKUP_INDEXED} indexed`:'');
} else if(filter==='new'){
$('#prog').style.width='0%';
$('#count').textContent = `${list.length} new SKU${list.length===1?'':'s'} to create`;
$('#remain').textContent = '📷 shoot to create + go live';
} else if(filter==='twil'){
const live=list.filter(x=>x.status==='ACTIVE').length;
$('#prog').style.width='0%';
$('#count').textContent = `${list.length} TWIL items · ${live} live`;
$('#remain').textContent = 'all grasscloth — photo or not';
} else if(recog){
$('#prog').style.width='0%';
$('#count').textContent = RECOGNIZED ? `${list.length} similar match${list.length===1?'':'es'}` : '🎨 Find Similar';
$('#remain').textContent = !RECOGNIZED ? '📷 photo a swatch to start'
: VRANK==='running' ? '🎨 ranking by image…'
: VRANK==='done' ? '✓ sorted by actual pattern image'
: 'ranked by pattern · whole catalog';
} else if(curated){
$('#prog').style.width='0%';
$('#count').textContent = filter==='fav' ? `${list.length} ⭐ favorite${list.length===1?'':'s'}` : `${list.length} recently updated`;
$('#remain').textContent = filter==='fav' ? 'tap ☆ on any card to add' : 'newest first';
} else {
const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
$('#prog').style.width=(total?done/total*100:0)+'%';
$('#count').textContent=`${done} / ${total} photographed`;
$('#remain').textContent=`${total-done} remaining`;
}
const typedQ=($('#q').value||'').trim();
const emptyMsg = filter==='shop' ? (typedQ?'No Shopify product matches that — try the SKU, pattern name, or vendor.':'🌐 Search EVERY Shopify SKU above — add or update a photo from your phone.')
: filter==='any' ? (typedQ?'No Fentucci product matches that — try the model # or DW SKU.':'🔎 Search any TWIL/Fentucci SKU above to add or replace its photo.')
: filter==='new' ? (typedQ?'No new SKU matches that.':'✅ All new SKUs created — none left to add.')
: filter==='fav' ? (typedQ?'No favorite matches that.':'⭐ No favorites yet — tap the ☆ star on any card to pin SKUs you update often.')
: filter==='recent' ? (typedQ?'No recent SKU matches that.':'🕘 Nothing updated yet — SKUs you photograph show up here, newest first.')
: filter==='similar' ? (RECOGNIZED?'No similar items found — try a flatter, closer shot of just the pattern.':'🎨 Tap 🎨 Similar above and photograph a swatch — the local AI reads its colors, motif & material, then finds this + lookalikes across the whole catalog.')
: 'Nothing here — switch filter or clear search.';
grid.innerHTML = list.length? '' : `<div class="empty">${emptyMsg}</div>`;
if(recog && RECOGNIZED) grid.insertAdjacentHTML('afterbegin', recogBanner());
// MEMORY GUARD (iOS OOM-freeze fix): never build more than RENDER_CAP card nodes. The TWIL list is
// ~88k items (36MB) — rendering them all drove the browser to 13GB and crashed the tab. Cap it and
// tell the user to search/filter to narrow. (Search/filter shrink `list` before this point.)
const RENDER_CAP=500;
const shown = list.length>RENDER_CAP ? list.slice(0,RENDER_CAP) : list;
for(const x of shown){
const isNew = x.needs_create && !x.done; // on the sheet but not yet created in Shopify
const photographed = !isNew && (x.done || !!x.image || x.live); // already has a real photo
const el=document.createElement('div'); el.className='card'+(photographed?' done':'');
const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.live?'<span class="badge b-live">● LIVE</span>':isNew?'<span class="badge b-new">✨ NEW</span>':photographed?'<span class="badge b-done">✓ PHOTO</span>':'<span class="badge b-need">NEEDS</span>';
const thumbUrl=x.photo||(isNew?'':x.image)||'';
const thumb=thumbUrl?`data-bg="${thumbUrl}"`:''; // lazy: bg applied on-visible (see observer below)
const adjustable=photographed; // anything with a real photo can be re-opened in the editor
el.innerHTML=`
<div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${thumbUrl?'':'▦'}${badge}${x.product_id?`<button class="fav-btn${x.fav?' on':''}" title="favorite">${x.fav?'★':'☆'}</button>`:''}</div>
<div class="body">
<div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
<div class="model" title="tap to copy model #" onclick="navigator.clipboard&&navigator.clipboard.writeText('${x.mfr||''}')"><small>Model #</small><b>${x.mfr||'—'}</b></div>
<div class="sub">
<span class="sku">${x.dw_sku||'—'}</span>
<span class="price ${priced(x)?'':'no'}">${priced(x)?'$'+x.price:'no price'}</span>
<span>${isNew?'NEW · not on Shopify':x.status}</span>
</div>
${(x.collections&&x.collections.length)?`<div class="cols">${x.collections.slice(0,4).map(c=>'<span class="colchip">📚 '+c+'</span>').join('')}</div>`:''}
${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.live?(x.created?' · 🟢 CREATED + LIVE':' · 🟢 LIVE on store'):x.shopify_pushed?(x.live_reason?(' · photo on Shopify ('+x.live_reason+')'):' · photo on Shopify'):''}</div>`:''}
${x.push_err?`<div class="when" style="color:var(--red)">${x.push_err}</div>`:''}
${(x.keep_images&&x.image)?`<label class="onlyimg${x._replaceAll?' armed':''}"><input type="checkbox" class="onlyimg-cb"${x._replaceAll?' checked':''}> Replace all — make this the only image</label>`:''}
<div class="actions">
${adjustable?'<button class="enh">✨ Enhance</button>':''}
${x.product_id?'<button class="gal">🖼 Photos</button>':''}
<label class="shoot">${isNew?'📷 Create + Go Live':photographed?'📷 Update Photo':'📷 Add Photo'}<input type="file" accept="image/*" capture="${camFacing==='user'?'user':'environment'}"></label>
${(x.done||x.live||x.image||isNew)?'':'<button class="skip">Skip</button>'}
</div>
</div>`;
const editSrc=()=> (x.photo||('/api/current-image?pid='+x.product_id))+(x.photo?'?':'&')+'t='+Date.now();
el.querySelector('.shoot input').addEventListener('change',e=>shoot(x,e.target.files[0]));
const gal=el.querySelector('.gal'); if(gal) gal.addEventListener('click',()=>openGallery(x));
const oi=el.querySelector('.onlyimg-cb'); if(oi) oi.addEventListener('change',e=>{ x._replaceAll=e.target.checked; el.querySelector('.onlyimg').classList.toggle('armed',e.target.checked); });
const enh=el.querySelector('.enh'); if(enh) enh.addEventListener('click',()=>openEditor(x,editSrc()));
const th=el.querySelector('.thumb.tap'); if(th) th.addEventListener('click',()=>openEditor(x,editSrc()));
const fb=el.querySelector('.fav-btn'); if(fb) fb.addEventListener('click',ev=>{ev.stopPropagation();toggleFav(x,fb);});
const sk=el.querySelector('.skip'); if(sk) sk.addEventListener('click',()=>skip(x));
grid.appendChild(el);
}
if(list.length>shown.length){ const n=document.createElement('div'); n.className='empty'; n.style.gridColumn='1/-1';
n.innerHTML=`Showing first <b>${shown.length}</b> of <b>${list.length.toLocaleString()}</b> — 🔎 search a SKU / name / vendor to narrow.`; grid.appendChild(n); }
lazyThumbs(); // only load thumbnails as they scroll into view (huge mobile win vs 100s of images at once)
}
// CSS background-images can't use loading="lazy" — apply each thumb's bg only when it nears the viewport.
let _thumbObs=null;
function lazyThumbs(){
if(_thumbObs) _thumbObs.disconnect();
if(!('IntersectionObserver' in window)){ grid.querySelectorAll('.thumb[data-bg]').forEach(t=>{ t.style.backgroundImage=`url('${t.getAttribute('data-bg')}')`; t.removeAttribute('data-bg'); }); return; }
_thumbObs=new IntersectionObserver((es,o)=>{ for(const e of es){ if(e.isIntersecting){ const t=e.target,u=t.getAttribute('data-bg'); if(u){ t.style.backgroundImage=`url('${u}')`; t.removeAttribute('data-bg'); } o.unobserve(t); } } },{rootMargin:'400px'});
grid.querySelectorAll('.thumb[data-bg]').forEach(t=>_thumbObs.observe(t));
}
// iOS FREEZE FIX (DTD verdict B): NO client-side decode anywhere. v99 proved even createImageBitmap
// OOM-freezes iOS on a 12MP camera photo. downscale() now just reads the RAW file to a data URL via
// FileReader (no <img>/canvas/bitmap) — the SERVER (normalizeImage: ImageMagick → auto-orient → ≤1600px
// JPEG, handles HEIC) resizes at every ingest endpoint. `max` is ignored (kept for call-site compat).
async function downscale(file,max){
const raw=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
if(raw) return raw;
return new Promise((res)=>{
const img=new Image(), url=URL.createObjectURL(file);
let done=false; const finish=v=>{ if(done)return; done=true; try{URL.revokeObjectURL(url);}catch(e){} res(v); };
img.onload=()=>{
let{width:w,height:h}=img; const s=Math.min(1,(max||1200)/Math.max(w,h));
const c=document.createElement('canvas'); c.width=Math.round(w*s); c.height=Math.round(h*s);
c.getContext('2d').drawImage(img,0,0,c.width,c.height); finish(c.toDataURL('image/jpeg',0.82));
};
img.onerror=()=>finish(null);
setTimeout(()=>finish(null),12000);
img.src=url;
});
}
// Capture → open the editor (review + enhance) instead of uploading immediately.
async function shoot(x,file){
if(!file)return;
toast('Loading editor…');
const dataUrl=await downscale(file); // honors never-upscale (shrink only)
openEditor(x,dataUrl);
}
/* ===== Photo gallery: see every photo on a SKU, keep / remove / feature / add ===== */
let GAL_X=null;
async function openGallery(x){
if(!x.product_id){ toast('Create the product first (take a photo)'); return; }
GAL_X=x;
$('#galName').textContent=(x.title||'').replace(/ \| Fentucci$/,'');
$('#galSku').textContent=x.dw_sku||'';
$('#galAnswer').hidden=true; $('#galAnswer').innerHTML=''; // clear any prior Q&A
$('#gallery').hidden=false; document.body.style.overflow='hidden';
await renderGallery();
}
function closeGallery(){ $('#gallery').hidden=true; document.body.style.overflow=''; GAL_X=null; if(filter!=='any'&&filter!=='shop'&&filter!=='new') load(); }
async function renderGallery(){
const g=$('#galGrid'); g.innerHTML='<div class="gal-empty">Loading…</div>';
try{
const r=await fetch('/api/images?pid='+GAL_X.product_id); const d=await r.json();
const imgs=d.images||[];
if(!imgs.length){ g.innerHTML='<div class="gal-empty">No photos yet — tap 📷 Add for one, or ➕ Multiple to add several.</div>'; return; }
g.innerHTML='';
imgs.forEach((im,i)=>{
const feat=im.position===1;
const t=document.createElement('div'); t.className='gtile'+(feat?' feat':'');
t.innerHTML=`<img src="${im.src}" loading="lazy">${feat?'<span class="gfeat-badge">★ FEATURED</span>':''}
<div class="gtile-acts">${feat?'':'<button class="g-feat">★ Feature</button>'}<button class="g-del">✕ Remove</button></div>`;
const f=t.querySelector('.g-feat'); if(f) f.addEventListener('click',()=>imgAction('feature',im.id));
t.querySelector('.g-del').addEventListener('click',()=>{ if(confirm('Remove this photo?')) imgAction('delete',im.id); });
g.appendChild(t);
});
}catch(e){ g.innerHTML='<div class="gal-empty">Couldn\'t load photos.</div>'; }
}
async function imgAction(act,image_id){
toast(act==='delete'?'Removing…':'Setting featured…');
try{ const r=await fetch('/api/image/'+act,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid:GAL_X.product_id,image_id})});
const d=await r.json(); if(d.ok){ toast(act==='delete'?'Removed':'Featured ✓'); renderGallery(); } else toast('Failed'); }catch(e){ toast('Failed'); }
}
$('#galClose').addEventListener('click',closeGallery);
$('#galAsk').addEventListener('click',voiceAsk);
// Batch add: take several photos (in your camera roll), pick them all here, save at once.
$('#galAddMulti').addEventListener('change',async e=>{
const files=[...e.target.files]; e.target.value=''; if(!files.length||!GAL_X)return;
if(files.length>12){ toast('Max 12 at a time — adding first 12'); files.length=12; }
toast('Adding '+files.length+' photo'+(files.length>1?'s':'')+'…');
try{
const dataUrls=[]; for(const f of files){ dataUrls.push(await downscale(f)); } // never-upscale each
const r=await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrls,meta:cardMeta(GAL_X)})});
const d=await r.json();
if(d.ok){ toast('✓ Added '+d.added+' photo'+(d.added>1?'s':'')+((d.errors&&d.errors.length)?(' · '+d.errors.length+' failed'):'')+(d.live?' · 🟢 LIVE':'')); renderGallery(); }
else toast('Add failed'+(d.err?': '+d.err:''));
}catch(err){ toast('Upload failed — check connection'); }
});
$('#galAdd').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return;
// add a photo to this SKU's gallery (keep existing), via the editor
const gx={...GAL_X, keep_images:true, _replaceAll:false, _fromGallery:true};
$('#gallery').hidden=true; document.body.style.overflow='';
shoot(gx,f);
});
$('#galVid').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return; e.target.value='';
if(!GAL_X.product_id){ toast('Create the product first'); return; }
const mb=(f.size/1048576);
if(mb>150){ toast('Video too large ('+mb.toFixed(0)+'MB) — keep under 150MB'); return; }
toast('🎥 Uploading video ('+mb.toFixed(1)+'MB)…');
const rd=new FileReader();
rd.onload=async()=>{
try{ const r=await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrl:rd.result})});
const d=await r.json();
toast(d.ok?'🎥 Video added — Shopify is processing it':('Video failed: '+(d.err||'error')));
}catch(err){ toast('Video upload failed — check connection'); }
};
rd.readAsDataURL(f);
});
// Send the (possibly edited) photo to the server → local save + Shopify push + activation gate.
async function uploadPhoto(x,dataUrl){
toast((x.needs_create?'Creating ':'Uploading ')+x.dw_sku+'…');
try{
const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl,keep_images:!!x.keep_images&&!x._replaceAll,meta:cardMeta(x)})});
const d=await r.json();
if(d.ok){ Object.assign(x,{done:true,needs_create:false,created:d.created,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err,live:d.live,live_reason:d.live_reason});
toast(d.created&&d.live?('🟢 Created + LIVE · '+x.dw_sku):d.created?('Created (draft) · '+x.dw_sku+(d.live_reason?' — '+d.live_reason:'')):d.live?('🟢 '+x.dw_sku+' is LIVE!'):d.shopify_pushed?('✓ Photo on Shopify · '+x.dw_sku+(d.live_reason?(' (needs '+d.live_reason.replace('needs ','')+')'):'')):('Saved '+x.dw_sku+(d.push_err?' (push failed)':''))); render();
if(x._fromGallery&&x.product_id) setTimeout(()=>openGallery(x),400); // back to the gallery to see the added photo
} else toast('Error: '+(d.err||'failed'));
}catch(e){toast('Upload failed — check connection');}
}
/* ===================== Photo editor ===================== */
let ES=null, edRAF=0;
const WHEEL_R=64; // half of 128
function openEditor(x,dataUrl){
const img=new Image();
img.onload=()=>{
const flat=document.createElement('canvas'); flat.width=img.width; flat.height=img.height;
ES={x,img,flat,b:1,c:1,s:1,h:0,th:0,ts:0,rollMode:'off',curl:0.5};
const cv=$('#edcanvas'); cv.width=img.width; cv.height=img.height;
$('#ed-sku').textContent=x.dw_sku||'';
// reset controls
$('#edB').value=1; $('#edC').value=1; $('#edS').value=1; $('#edH').value=0;
$('#edCurl').value=0.5; $('#edCurlv').textContent='50%'; $('#edCurlRow').style.display='none';
setThumb(0,0); $('#wheelVal').textContent='neutral';
$('#edRoll').classList.remove('on'); $('#edRoll').textContent='🗞 Roll';
syncLabels(); edRedraw();
$('#editor').hidden=false; document.body.style.overflow='hidden';
};
img.onerror=()=>toast('Could not load image to edit');
img.src=dataUrl;
}
function closeEditor(){ $('#editor').hidden=true; document.body.style.overflow=''; ES=null; }
function syncLabels(){
$('#edBv').textContent=signPct($('#edB').value);
$('#edCv').textContent=signPct($('#edC').value);
$('#edSv').textContent=signPct($('#edS').value);
$('#edHv').textContent=Math.round(+$('#edH').value)+'°';
}
function signPct(v){const p=Math.round((+v-1)*100); return (p>0?'+':'')+p;}
function scheduleRedraw(){ if(edRAF)return; edRAF=requestAnimationFrame(()=>{edRAF=0;edRedraw();}); }
const _cl=v=>v<0?0:v>255?255:v;
function _hsl2rgb(h,s,l){ // h 0..1
const q=l<.5?l*(1+s):l+s-l*s, p=2*l-q;
const f=t=>{t=(t+1)%1; return t<1/6?p+(q-p)*6*t:t<1/2?q:t<2/3?p+(q-p)*(2/3-t)*6:p;};
return [f(h+1/3)*255,f(h)*255,f(h-1/3)*255];
}
// Bake the slider adjustments into real pixels (reliable on every device — no ctx.filter).
function bakeAdjust(src,w,h){
const c=document.createElement('canvas'); c.width=w; c.height=h;
const ctx=c.getContext('2d'); ctx.drawImage(src,0,0,w,h);
const id=ctx.getImageData(0,0,w,h), d=id.data;
const b=+$('#edB').value, con=+$('#edC').value, sat=+$('#edS').value, hue=(+$('#edH').value)*Math.PI/180;
const cos=Math.cos(hue), sn=Math.sin(hue);
const m=[0.213+cos*0.787-sn*0.213,0.715-cos*0.715-sn*0.715,0.072-cos*0.072+sn*0.928,
0.213-cos*0.213+sn*0.143,0.715+cos*0.285+sn*0.140,0.072-cos*0.072-sn*0.283,
0.213-cos*0.213-sn*0.787,0.715-cos*0.715+sn*0.715,0.072+cos*0.928+sn*0.072];
const castOn=ES&&ES.ts>0.04; let cr=0,cg=0,cb=0,ca=0;
if(castOn){ const t=_hsl2rgb((ES.th||0)/360,1,.5); cr=t[0];cg=t[1];cb=t[2]; ca=ES.ts*0.6; }
// Clamp after EACH stage to match CSS/SVG filter semantics — otherwise inter-stage
// overshoot (>255) gets pulled back differently and the export drifts from the preview.
for(let i=0;i<d.length;i+=4){
let r=d[i],g=d[i+1],bl=d[i+2];
r=_cl(r*b);g=_cl(g*b);bl=_cl(bl*b); // brightness
r=_cl((r-128)*con+128);g=_cl((g-128)*con+128);bl=_cl((bl-128)*con+128); // contrast
const L=0.2126*r+0.7152*g+0.0722*bl; // saturation
r=_cl(L+(r-L)*sat);g=_cl(L+(g-L)*sat);bl=_cl(L+(bl-L)*sat);
const nr=_cl(r*m[0]+g*m[1]+bl*m[2]),ng=_cl(r*m[3]+g*m[4]+bl*m[5]),nb=_cl(r*m[6]+g*m[7]+bl*m[8]); // hue
r=nr;g=ng;bl=nb;
if(castOn){ r=r*(1-ca)+cr*ca;g=g*(1-ca)+cg*ca;bl=bl*(1-ca)+cb*ca; } // color cast
d[i]=_cl(r);d[i+1]=_cl(g);d[i+2]=_cl(bl);
}
ctx.putImageData(id,0,0); return c;
}
function edRedraw(){
if(!ES)return;
ES.b=+$('#edB').value; ES.c=+$('#edC').value; ES.s=+$('#edS').value; ES.h=+$('#edH').value;
const cv=$('#edcanvas'), ctx=cv.getContext('2d');
if(ES.rollMode==='tube'||ES.rollMode==='sheet'){
cv.style.filter='none';
const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
ctx.clearRect(0,0,cv.width,cv.height);
(ES.rollMode==='tube'?renderRoll:renderSheet)(fc,ctx,cv.width,cv.height);
} else {
// FLAT: preview from the SAME baked pixels we export, so what you see IS what saves
// (bakeAdjust is manual getImageData math — iOS-reliable, unlike ctx.filter).
cv.style.filter='none';
const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
ctx.clearRect(0,0,cv.width,cv.height);
ctx.drawImage(fc,0,0);
}
}
// Wrap the flat texture around a vertical cylinder ("roll around a tube,
// front-to-back") with Lambertian shading + sheen + contact shadow on a dark ground.
function renderRoll(src, o, W, H){
o.fillStyle='#0c0b09'; o.fillRect(0,0,W,H); // dark studio ground
const t=(ES&&ES.curl!=null)?ES.curl:0.5; // 0 = loose/gentle → 1 = tight tube
const cx=W/2, R=W*(0.52-t*0.18), cov=Math.PI*(0.62+t*0.52); // tighter ⇒ smaller radius, more wrap
const shExp=0.55+t*0.55; // tighter ⇒ deeper edge shading
const topPad=H*0.045, botPad=H*0.11, hh=H-topPad-botPad;
// soft contact shadow beneath the roll
o.save(); o.globalAlpha=.45; o.fillStyle='#000';
o.beginPath(); o.ellipse(cx, H-botPad*0.55, R*0.96, botPad*0.5, 0, 0, 7); o.fill(); o.restore();
const N=Math.min(W, 640);
for(let i=0;i<N;i++){
const sx=(i/(N-1)-0.5)*2*R; // screen offset from centre, −R..R
const cl=Math.max(-0.999,Math.min(0.999,sx/R));
const th=Math.asin(cl); // angle on the cylinder
const u=th/cov+0.5; if(u<0||u>1) continue; // map to source column
const screenX=cx+sx, stripW=(2*R)/N+1.4;
o.drawImage(src, u*(src.width-1),0,1,src.height, screenX, topPad, stripW, hh);
const shade=Math.pow(Math.cos(th),shExp); // bright at front → dark at the curl-away edges
o.globalCompositeOperation='multiply';
o.fillStyle='rgba(0,0,0,'+(1-shade).toFixed(3)+')';
o.fillRect(screenX, topPad, stripW, hh);
o.globalCompositeOperation='source-over';
}
// specular sheen down the front-left quarter
const sg=o.createLinearGradient(cx-R,0,cx+R,0);
sg.addColorStop(0,'rgba(255,255,255,0)'); sg.addColorStop(.40,'rgba(255,255,255,.18)');
sg.addColorStop(.52,'rgba(255,255,255,.04)'); sg.addColorStop(1,'rgba(255,255,255,0)');
o.globalCompositeOperation='soft-light'; o.fillStyle=sg; o.fillRect(cx-R,topPad,2*R,hh);
o.globalCompositeOperation='source-over';
}
// Curled hanging SHEET (the PR roll-shot look): top faces the viewer, the sheet
// bows forward and the bottom edge curls UNDER, revealing the paper's cream back.
function renderSheet(src, o, W, H){
o.fillStyle='#0c0b09'; o.fillRect(0,0,W,H); // dark studio ground
const t=(ES&&ES.curl!=null)?ES.curl:0.5;
const margin=W*0.07, x0=margin, drawW=W-2*margin, top=H*0.05;
const phiMax=Math.PI*(0.58+t*0.42); // how far it curls under (105°→180°)
const ease=1.4+t*0.9; // tighter ⇒ bend concentrated at the bottom
const shExp=0.6+t*0.5;
const steps=src.height, paperLen=H*0.94, ds=paperLen/steps;
let y=top, minY=top;
for(let i=0;i<steps;i++){
const s=i/(steps-1), phi=Math.pow(s,ease)*phiMax;
const dy=Math.cos(phi)*ds, bandH=Math.max(1,Math.abs(dy)+1.3);
const yTop=dy>=0?y:y+dy;
if(phi<=Math.PI/2){ // front face → texture + Lambert shade
o.drawImage(src,0,i,src.width,1, x0,yTop,drawW,bandH);
o.globalCompositeOperation='multiply';
o.fillStyle='rgba(0,0,0,'+(1-Math.pow(Math.cos(phi),shExp)).toFixed(3)+')';
o.fillRect(x0,yTop,drawW,bandH); o.globalCompositeOperation='source-over';
} else { // past vertical → cream UNDERSIDE of the paper
const b=Math.pow(Math.max(0,Math.cos(Math.PI-phi)),0.8), lum=Math.round(150+72*b);
o.fillStyle='rgb('+lum+','+(lum-10)+','+(lum-30)+')';
o.fillRect(x0,yTop,drawW,bandH);
}
y+=dy; if(y<minY)minY=y;
}
// soft shadow line where the curl lifts off the front
o.fillStyle='rgba(0,0,0,.18)'; o.fillRect(x0,minY-2,drawW,3);
}
// paint the color wheel once (white center → full hue at rim)
(function paintWheel(){
const w=$('#wheel'), ctx=w.getContext('2d'), R=w.width/2;
for(let a=0;a<360;a++){
ctx.beginPath(); ctx.moveTo(R,R);
ctx.arc(R,R,R,(a-1)*Math.PI/180,(a+1)*Math.PI/180); ctx.closePath();
const g=ctx.createRadialGradient(R,R,0,R,R,R);
g.addColorStop(0,'#fff'); g.addColorStop(1,`hsl(${a},100%,50%)`);
ctx.fillStyle=g; ctx.fill();
}
})();
function setThumb(dx,dy){ $('#wheelThumb').style.left=(WHEEL_R+dx)+'px'; $('#wheelThumb').style.top=(WHEEL_R+dy)+'px'; }
function wheelPick(ev){
if(!ES)return; ev.preventDefault();
const w=$('#wheel'), r=w.getBoundingClientRect();
const t=ev.touches?ev.touches[0]:ev;
let dx=(t.clientX-r.left)*(w.width/r.width)-WHEEL_R;
let dy=(t.clientY-r.top)*(w.height/r.height)-WHEEL_R;
let d=Math.hypot(dx,dy);
if(d>WHEEL_R){dx*=WHEEL_R/d;dy*=WHEEL_R/d;d=WHEEL_R;}
let hue=Math.atan2(dy,dx)*180/Math.PI; if(hue<0)hue+=360;
ES.th=hue; ES.ts=d/WHEEL_R;
setThumb(dx,dy);
$('#wheelVal').textContent= ES.ts<0.04?'neutral':(Math.round(hue)+'° · '+Math.round(ES.ts*100)+'%');
scheduleRedraw();
}
const wb=$('#wheelBox');
wb.addEventListener('pointerdown',e=>{wb.setPointerCapture(e.pointerId);wheelPick(e);});
wb.addEventListener('pointermove',e=>{if(e.buttons||e.pressure)wheelPick(e);});
['edB','edC','edS','edH'].forEach(id=>$('#'+id).addEventListener('input',()=>{syncLabels();scheduleRedraw();}));
$('#edCurl').addEventListener('input',e=>{
if(ES)ES.curl=+e.target.value; $('#edCurlv').textContent=Math.round(+e.target.value*100)+'%'; scheduleRedraw();
});
$('#edRoll').addEventListener('click',()=>{
if(!ES)return;
ES.rollMode = ES.rollMode==='off'?'tube':ES.rollMode==='tube'?'sheet':'off';
const on=ES.rollMode!=='off';
$('#edRoll').classList.toggle('on',on);
$('#edRoll').textContent = ES.rollMode==='tube'?'🗞 Tube':ES.rollMode==='sheet'?'🗞 Sheet':'🗞 Roll';
$('#edCurlRow').style.display=on?'grid':'none';
toast(ES.rollMode==='tube'?'Rolled around tube':ES.rollMode==='sheet'?'Curled sheet':'Flat view');
edRedraw();
});
$('#edReset').addEventListener('click',()=>{
$('#edB').value=1;$('#edC').value=1;$('#edS').value=1;$('#edH').value=0;
$('#edCurl').value=0.5; $('#edCurlv').textContent='50%'; $('#edCurlRow').style.display='none';
if(ES){ES.th=0;ES.ts=0;ES.rollMode='off';ES.curl=0.5;} setThumb(0,0); $('#wheelVal').textContent='neutral';
$('#edRoll').classList.remove('on'); $('#edRoll').textContent='🗞 Roll'; syncLabels(); edRedraw();
});
$('#edCancel').addEventListener('click',()=>{toast('Edit cancelled');closeEditor();});
/* ===== Crop tool (drag corners / move box; dims outside) ===== */
let CROP=null;
function enterCrop(){
if(!ES)return;
const cv=$('#edcanvas'), r=cv.getBoundingClientRect();
CROP={r, iw:ES.img.width, ih:ES.img.height};
setCropBox(r.left+r.width*0.08, r.top+r.height*0.08, r.width*0.84, r.height*0.84);
$('#cropov').hidden=false;
}
function exitCrop(){ $('#cropov').hidden=true; CROP=null; }
function setCropBox(x,y,w,h){
const b=$('#cropbox'); b.style.left=x+'px'; b.style.top=y+'px'; b.style.width=Math.max(30,w)+'px'; b.style.height=Math.max(30,h)+'px';
}
let cropRatio=0; // 0 = free, else width/height
function setPreset(rat,btn){
cropRatio=+rat||0;
document.querySelectorAll('#cropPresets button').forEach(b=>b.classList.remove('on')); if(btn)btn.classList.add('on');
if(!CROP||!cropRatio)return;
const r=CROP.r, bb=$('#cropbox').getBoundingClientRect(), cx=bb.left+bb.width/2, cy=bb.top+bb.height/2;
let w=bb.width, ht=w/cropRatio;
if(ht>r.height*0.96){ ht=r.height*0.92; w=ht*cropRatio; }
if(w>r.width*0.96){ w=r.width*0.92; ht=w/cropRatio; }
const x=Math.max(r.left,Math.min(cx-w/2,r.right-w)), y=Math.max(r.top,Math.min(cy-ht/2,r.bottom-ht));
setCropBox(x,y,w,ht);
}
(function cropDrag(){
const box=$('#cropbox'); let mode=null,h=null,sx,sy,ox,oy,ow,oh;
function down(e){ if(!CROP)return; e.preventDefault(); sx=e.clientX; sy=e.clientY;
const bb=box.getBoundingClientRect(); ox=bb.left;oy=bb.top;ow=bb.width;oh=bb.height;
h=e.target.dataset&&e.target.dataset.h; mode=h?'resize':'move';
document.addEventListener('pointermove',move,{passive:false}); document.addEventListener('pointerup',up); }
function move(e){ if(!mode||!CROP)return; e.preventDefault(); const r=CROP.r;
if(mode==='move'){ const dx=e.clientX-sx, dy=e.clientY-sy;
setCropBox(Math.max(r.left,Math.min(ox+dx,r.right-ow)), Math.max(r.top,Math.min(oy+dy,r.bottom-oh)), ow, oh); return; }
// resize: opposite corner stays anchored
const ax = h.includes('w') ? ox+ow : ox, ay = h.includes('n') ? oy+oh : oy;
let px=Math.max(r.left,Math.min(e.clientX,r.right)), py=Math.max(r.top,Math.min(e.clientY,r.bottom));
let w=Math.abs(px-ax), ht=Math.abs(py-ay);
if(cropRatio){
if(w/cropRatio>=ht) ht=w/cropRatio; else w=ht*cropRatio;
if(h.includes('e')&&ax+w>r.right){ w=r.right-ax; ht=w/cropRatio; }
if(h.includes('w')&&ax-w<r.left){ w=ax-r.left; ht=w/cropRatio; }
if(h.includes('s')&&ay+ht>r.bottom){ ht=r.bottom-ay; w=ht*cropRatio; }
if(h.includes('n')&&ay-ht<r.top){ ht=ay-r.top; w=ht*cropRatio; }
}
w=Math.max(30,w); ht=Math.max(30,ht);
const x=h.includes('w')?ax-w:ax, y=h.includes('n')?ay-ht:ay;
setCropBox(x,y,w,ht); }
function up(){ mode=null; document.removeEventListener('pointermove',move); document.removeEventListener('pointerup',up); }
box.addEventListener('pointerdown',down);
})();
document.querySelectorAll('#cropPresets button').forEach(b=>b.addEventListener('click',()=>setPreset(b.dataset.r,b)));
$('#edCrop').addEventListener('click',()=>{ enterCrop(); const f=document.querySelector('#cropPresets button[data-r="0"]'); setPreset(0,f); });
$('#cropCancel').addEventListener('click',exitCrop);
$('#cropApply').addEventListener('click',()=>{
if(!CROP||!ES)return; const r=CROP.r, bb=$('#cropbox').getBoundingClientRect();
const sx=(bb.left-r.left)/r.width*CROP.iw, sy=(bb.top-r.top)/r.height*CROP.ih;
const sw=bb.width/r.width*CROP.iw, sh=bb.height/r.height*CROP.ih;
const c=document.createElement('canvas'); c.width=Math.round(sw); c.height=Math.round(sh);
c.getContext('2d').drawImage(ES.img, sx,sy,sw,sh, 0,0,c.width,c.height);
const ni=new Image();
ni.onload=()=>{ ES.img=ni; ES.flat=document.createElement('canvas'); ES.flat.width=ni.width; ES.flat.height=ni.height;
const cv=$('#edcanvas'); cv.width=ni.width; cv.height=ni.height; edRedraw(); exitCrop(); toast('Cropped'); };
ni.src=c.toDataURL('image/jpeg',0.95);
});
$('#edUse').addEventListener('click',()=>{
if(!ES)return; const x=ES.x;
// BAKE the adjustment into pixels (CSS preview filter does NOT survive toDataURL).
let dataUrl;
if(ES.rollMode==='tube'||ES.rollMode==='sheet'){ edRedraw(); dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9); }
else { dataUrl=bakeAdjust(ES.img,ES.img.width,ES.img.height).toDataURL('image/jpeg',0.9); }
closeEditor(); uploadPhoto(x,dataUrl);
});
async function skip(x){await fetch('/api/skip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:x.dw_sku,skipped:true})});x.skipped=true;render();}
$('#q').addEventListener('input',()=>{
collapsed=false;
if(filter==='any'){ clearTimeout(_lkT); _lkT=setTimeout(doLookup,250); }
else if(filter==='shop'){ clearTimeout(_lkT); _lkT=setTimeout(doShopSearch,350); } // a touch longer — each keystroke hits Shopify
else render();
});
$('#sort').addEventListener('change',e=>{setLS('sort',e.target.value);render();});
['input','change'].forEach(ev=>$('#dens').addEventListener(ev,e=>{applyDensity(e.target.value);setLS('dens',e.target.value);}));
document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
collapsed=false;
document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));c.classList.add('on');filter=c.dataset.f;setLS('filter',filter);
if(filter==='any'){ $('#q').placeholder='🔎 SKU, model #, name, or collection…'; $('#q').focus(); doLookup(); }
else if(filter==='shop'){ $('#q').placeholder='🌐 Any Shopify SKU, name, vendor, or collection…'; $('#q').focus(); doShopSearch(); }
else if(filter==='new'){ $('#q').placeholder='🔎 Filter the new SKUs…'; loadNew(); }
else if(filter==='twil'){ $('#q').placeholder='🔎 Filter TWIL items…'; loadTwil(); }
else if(filter==='fav'){ $('#q').placeholder='🔎 Filter favorites…'; render(); loadFav(); }
else if(filter==='recent'){ $('#q').placeholder='🔎 Filter recent…'; render(); loadRecent(); }
else if(filter==='similar'){ $('#q').placeholder='🎨 Photo a swatch to find similar'; if(!SIMILAR.length)$('#simInput').click(); else render(); }
else { $('#q').placeholder='🔎 Search name or model #…'; render(); }
}));
// 🔢 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,_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
const v=(camFacing==='user')?'user':'environment';
document.querySelectorAll('input[type=file][accept^="image"]').forEach(i=>{ if(i.hasAttribute('capture')) i.setAttribute('capture',v); });
}
async function startScanStream(){ // (re)acquire the live stream on the current facing
if(scanStream){ scanStream.getTracks().forEach(t=>t.stop()); scanStream=null; }
scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:camFacing},width:{ideal:1280}}});
const v=$('#scanvideo'); v.srcObject=scanStream; try{await v.play();}catch(e){}
}
// ── lock-in "yoo-hoo" cue (Web Audio, zero asset; must be unlocked by a user gesture on iOS) ──
let _audio=null;
function unlockAudio(){ try{ if(!_audio) _audio=new (window.AudioContext||window.webkitAudioContext)(); if(_audio.state==='suspended')_audio.resume(); }catch(e){} }
function tone(seq,type,vol){ // seq = [[freq,startOffset,dur],…]; one-shot Web Audio blip
try{
if(!_audio) return;
const ac=_audio, t=ac.currentTime, V=vol||0.3;
seq.forEach(([f,off,dur])=>{
const o=ac.createOscillator(), g=ac.createGain();
o.type=type||'triangle'; o.frequency.setValueAtTime(f,t+off);
g.gain.setValueAtTime(0.0001,t+off);
g.gain.exponentialRampToValueAtTime(V,t+off+0.03);
g.gain.exponentialRampToValueAtTime(0.0001,t+off+dur);
o.connect(g).connect(ac.destination); o.start(t+off); o.stop(t+off+dur+0.03);
});
}catch(e){}
}
function playYoohoo(){ tone([[784,0.00,0.17],[659,0.15,0.34]],'triangle',0.30); } // ✓ FOUND: bright falling "yoo-HOO"
function playUhoh(){ tone([[311,0.00,0.20],[233,0.20,0.36]],'square',0.22); } // ✗ NOT RIGHT: low descending "uh-oh"
// ── Voice search: speak a vendor / SKU / "what to look for" → transcribe → run the search ──
let _recog=null, _listening=false;
function syncMicBtn(){ const b=$('#micBtn'); if(b){ b.textContent='🎤'; b.classList.remove('on'); } }
function voiceSearch(){
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SR){ toast('Voice not supported here — tap the search box and use the keyboard mic 🎙️'); $('#q').focus(); return; }
if(_listening){ try{ _recog&&_recog.stop(); }catch(e){} return; } // tap again = stop
unlockAudio();
_recog=new SR(); _recog.lang='en-US'; _recog.interimResults=false; _recog.maxAlternatives=1;
_listening=true; const b=$('#micBtn'); if(b){ b.textContent='🔴'; b.classList.add('on'); }
toast('🎤 Listening — say a vendor, SKU, or what to look for…');
_recog.onresult=e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(t) runVoiceQuery(t); };
_recog.onerror=e=>{ toast('Mic: '+(e.error||'error')); };
_recog.onend=()=>{ _listening=false; syncMicBtn(); };
try{ _recog.start(); }catch(e){ _listening=false; syncMicBtn(); toast('Mic busy — try again'); }
}
function runVoiceQuery(t){
// strip spoken lead-ins ("show me…", "find…", "look for…") so the query is just the subject
const q=t.replace(/^\s*(hey |ok |okay )?(show me|find( me)?|search( for)?|look( for)?|pull up|get me|i want|do you have|any)\s+/i,'').trim() || t.trim();
$('#q').value=q;
toast('🔎 “'+q+'”');
// All-Shopify is the broadest lane — it resolves vendor names, product types, titles, SKUs,
// and (via the mfr# index) printed vendor codes. Exactly what "look for a vendor" needs.
chipTo('shop'); doShopSearch();
}
// ── Ask-the-SKU: speak a question about the OPEN product → local Ollama answers (spoken back) ──
function speak(text){ try{ const u=new SpeechSynthesisUtterance(text); u.rate=1.05; speechSynthesis.cancel(); speechSynthesis.speak(u); }catch(e){} }
function voiceAsk(){
if(!GAL_X||!GAL_X.product_id){ toast('Open a product first'); return; }
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SR){ const q=prompt('Ask about '+(GAL_X.dw_sku||'this SKU')+':'); if(q) askSku(q); return; }
if(_listening){ try{ _recog&&_recog.stop(); }catch(e){} return; }
unlockAudio();
_recog=new SR(); _recog.lang='en-US'; _recog.interimResults=false; _recog.maxAlternatives=1;
_listening=true; const b=$('#galAsk'); if(b) b.textContent='🔴 …';
toast('🎤 Ask about this SKU…');
_recog.onresult=e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(t) askSku(t); };
_recog.onerror=e=>{ toast('Mic: '+(e.error||'error')); };
_recog.onend=()=>{ _listening=false; const b=$('#galAsk'); if(b) b.textContent='🎤 Ask'; };
try{ _recog.start(); }catch(e){ _listening=false; toast('Mic busy — try again'); }
}
async function askSku(question){
if(!GAL_X||!GAL_X.product_id) return;
const a=$('#galAnswer'); a.hidden=false; a.innerHTML='<span class="qq">'+question+'</span>💭 thinking…';
try{
const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:GAL_X.product_id,question})});
const d=await r.json(); const ans=d.answer||'No answer.';
a.innerHTML='<span class="qq">'+question+'</span>'+ans;
speak(ans);
}catch(e){ a.innerHTML='<span class="qq">'+question+'</span>⚠️ AI unreachable on the LAN.'; }
}
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;_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,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');
if(!$('#scanlive').hidden){ try{ await startScanStream(); }catch(e){ toast('Could not switch camera'); } }
else toast(camFacing==='user'?'📷 Front camera':'📷 Back camera');
}
function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} _frontHits=0; resetScanFields(); $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
// Live field readout: fill SKU#/Model#/Name/Color and flip each chip GREEN the moment it's detected.
// Sticky — once a field is read it stays green (a later blank frame won't wipe a good read).
let _sfSeen={};
function updateScanFields(f){
f=f||{};
document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{
const k=ch.getAttribute('data-k'); const v=(f[k]!=null&&String(f[k]).trim())?String(f[k]).trim():(_sfSeen[k]||'');
if(v){ _sfSeen[k]=v; ch.classList.add('on'); ch.querySelector('i').textContent=v; }
});
}
function resetScanFields(){ _sfSeen={}; document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{ ch.classList.remove('on'); ch.querySelector('i').textContent='—'; }); }
async function ocrPost(dataUrl){ const r=await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})}); return r.json(); }
function mirrorDataUrl(dataUrl){ return new Promise((res,rej)=>{ const im=new Image(); im.onload=()=>{ const c=document.createElement('canvas'); c.width=im.naturalWidth; c.height=im.naturalHeight; const x=c.getContext('2d'); x.translate(c.width,0); x.scale(-1,1); x.drawImage(im,0,0); res(c.toDataURL('image/jpeg',0.8)); }; im.onerror=rej; im.src=dataUrl; }); }
// front camera can deliver a MIRRORED frame (iOS) → backwards text reads as garbage. If a front-cam read finds nothing, retry flipped.
async function ocrSmart(dataUrl){
let d=await ocrPost(dataUrl);
if(camFacing==='user' && (!d||!d.candidates||!d.candidates.length)){
try{ const d2=await ocrPost(await mirrorDataUrl(dataUrl)); if(d2&&d2.candidates&&d2.candidates.length) return d2; }catch(e){}
}
return d;
}
async function scanTick(){
if(scanBusy)return; scanBusy=true;
try{
const v=$('#scanvideo'); if(!v.videoWidth){scanBusy=false;return;}
const VW=v.videoWidth, VH=v.videoHeight;
// full frame — kept for logo-ID + pattern-match (which want the whole swatch, not a text strip)
const s=Math.min(1,1280/Math.max(VW,VH));
const c=document.createElement('canvas'); c.width=Math.round(VW*s); c.height=Math.round(VH*s);
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
// HONE IN ON THE TEXT: crop to the centered guide box (~86% wide, 5:2 like .scan-frame) and
// UPSCALE it (up to 2x, cap 1600px) so small printed SKUs get more pixels for OCR and the
// surrounding clutter is dropped — a sharper, tighter read than OCRing the whole frame.
const roiW=Math.round(VW*0.86), roiH=Math.min(Math.round(roiW*2/5), Math.round(VH*0.92));
const roiX=Math.round((VW-roiW)/2), roiY=Math.round((VH-roiH)/2);
const TW=Math.min(1600,Math.round(roiW*2));
const cRoi=document.createElement('canvas'); cRoi.width=TW; cRoi.height=Math.round(roiH*TW/roiW);
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;}
// FAST LOCK: a strong read (known vendor prefix, or the brand name is printed on the
// swatch and this is the biggest code) locks on the FIRST sighting; anything weaker
// waits for a 2nd matching frame so a shaky/garbled read can't false-lock.
const locked=_lockHits>=2 || (d.topStrong && _lockHits>=1);
const vtag=d.vendor ? d.vendor+' · ' : '';
// a BARCODE read is exact (not OCR-guessed) → label it distinctly so the user trusts it.
const isBarcode=d.barcode && d.barcode===d.top;
const lockLabel=isBarcode ? '▍▍ BARCODE · ' : '🔒 LOCKED · ';
// tentative cue: a non-strong read needs a 2nd matching frame to lock — coach the user
// to hold steady (reduces the "why won't it lock" confusion); strong reads lock instantly.
const tentative=d.topStrong ? '👀 ' : '👀 hold steady · ';
$('#scanStatus').textContent=(locked?lockLabel:tentative)+vtag+d.top;
if(locked){
$('#scanlive').classList.add('locked'); // turn the frame + pill RED — "locked in"
try{navigator.vibrate&&navigator.vibrate(70);}catch(e){} // haptic on lock; audio waits for the resolve outcome
if(scanTimer){clearInterval(scanTimer);scanTimer=null;} // freeze ticks on the red frame
const hit=d;
const frame=c.toDataURL('image/jpeg',0.8); // keep the locked frame for a background logo-ID if no vendor
setTimeout(()=>{ closeLiveScan(); applyScan(hit, frame); },420); // hold the red a beat, then GO
}
} else if(d && d.side==='front'){
// Pattern face — no SKU on this side. Hold steady a few frames, then auto-MATCH the design
// (reuse the recognize path) instead of dead-ending; user can still flip for the exact SKU.
_lastTop=null; _lockHits=0; _frontHits++;
if(_frontHits>=3){
$('#scanStatus').textContent='🎨 Matching this design…';
$('#scanlive').classList.add('locked');
try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}
if(scanTimer){clearInterval(scanTimer);scanTimer=null;}
const frame=c.toDataURL('image/jpeg',0.85);
setTimeout(()=>{ closeLiveScan(); recognizeDataUrl(frame); },300);
} else {
$('#scanStatus').textContent='🎨 Front (pattern) · hold steady to match the design, or flip for the SKU';
}
}
else if(_lockHits<2){ $('#scanStatus').textContent='📄 Point at the printed SKU…'; }
}catch(e){} finally{ scanBusy=false; }
}
// multi-pass resolver: try EVERY OCR candidate against TWIL (dw_sku+mfr+name) then ALL-Shopify; first hit wins
function chipTo(f){
document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));
const sc=document.querySelector('.chip[data-f="'+f+'"]'); if(sc)sc.classList.add('on');
filter=f; setLS('filter',f); $('#q').placeholder='🔎 SKU, model #, name, or collection…';
}
async function scanResolve(cands){
for(const c of (cands||[])){ // pass 1 — TWIL catalog (dw_sku + mfr# + name + collection in hay)
try{ const d=await (await fetch('/api/lookup?q='+encodeURIComponent(c))).json();
if(d&&d.items&&d.items.length) return {via:'any',q:c,n:d.items.length,vendor:(d.items[0]&&d.items[0].vendor)||''}; }catch(e){}
}
for(const c of (cands||[])){ // pass 2 — ALL Shopify (any vendor: dw sku / name / vendor)
try{ const d=await (await fetch('/api/shopify-search?q='+encodeURIComponent(c))).json();
if(d&&d.items&&d.items.length) return {via:'shop',q:c,n:d.items.length,vendor:(d.items[0]&&d.items[0].vendor)||''}; }catch(e){}
}
return null;
}
// Non-blocking logo-ID: when a code locked but OCR couldn't name the vendor, ask the VLM
// in the background (the resolve already ran — this never blocks it). If it finds a vendor,
// surface it and bind it to the locked SKU so the profile learns. See /api/identify.
function bgIdentify(imgDataUrl, sku){
if(!imgDataUrl) return;
fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:imgDataUrl})})
.then(r=>r.json()).then(v=>{
if(v&&v.vendor){
toast('🏷 Looks like '+v.vendor);
if(sku) fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sku:sku,vendor:v.vendor})}).catch(()=>{});
}
}).catch(()=>{});
}
async function applyScan(d, imgDataUrl){
const cands=(d&&d.candidates&&d.candidates.length)?d.candidates:(d&&d.top?[d.top]:[]);
if(!cands.length){ playUhoh(); toast('No SKU/name found — try a closer, flatter shot'); return; }
toast('🔎 Matching '+cands.length+' read'+(cands.length>1?'s':'')+'…');
const hit=await scanResolve(cands);
collapsed=false;
if(!hit){ // locked a code but nothing matched anywhere → "not right"
playUhoh(); chipTo('shop'); $('#q').value=cands[0]; toast('✗ No match for '+cands[0]+' — searching all Shopify');
if(!d.vendor) bgIdentify(imgDataUrl, cands[0]); // no vendor from OCR → try the logo in the background
return doShopSearch();
}
if(!hit.vendor && !d.vendor) bgIdentify(imgDataUrl, hit.q); // resolved a product but no vendor named → logo-ID
playYoohoo(); // ✓ resolved to a real product — the happy lock
// LEARN: this scan resolved to a known vendor — refine that vendor's sample profile
// (fire-and-forget; server folds in the on-swatch code prefix). See /learn.
if(hit.vendor){ const _f=(d&&d.fields)||{}; fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:(d&&d.text)||'',sku:hit.q,vendor:hit.vendor,name:_f.name||null,color:_f.color||null})}).catch(()=>{}); }
$('#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'));
// ── Front + Back identify: fuse back-label code + vendor + QR (client-decoded) with front visual ──
let _fbBack=null,_fbFront=null,_fbQR=null,_jsqr=null,_fbLookup=false;
function loadJsQR(){ if(_jsqr) return Promise.resolve(_jsqr); return new Promise((res)=>{ const s=document.createElement('script'); s.src='https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'; s.onload=()=>{_jsqr=window.jsQR;res(_jsqr);}; s.onerror=()=>res(null); document.head.appendChild(s); }); }
async function decodeQR(dataUrl){ const jsQR=await loadJsQR(); if(!jsQR) return null;
// iOS-safe: cap the QR canvas at ≤1000px (a full-12MP getImageData is a ~48MB array that freezes iOS).
// createImageBitmap decodes off-main-thread + low-memory; QR reads fine at this size.
try{
const blob=await(await fetch(dataUrl)).blob();
const bmp=await createImageBitmap(blob);
const s=Math.min(1,1000/Math.max(bmp.width,bmp.height));
const c=document.createElement('canvas'); c.width=Math.max(1,Math.round(bmp.width*s)); c.height=Math.max(1,Math.round(bmp.height*s));
const x=c.getContext('2d'); x.drawImage(bmp,0,0,c.width,c.height); if(bmp.close)bmp.close();
const d=x.getImageData(0,0,c.width,c.height); const q=jsQR(d.data,c.width,c.height); return q?q.data:null;
}catch(e){ return null; }
}
async function fbRead(file,isBack){ if(!file)return;
try{ $('#fbNote').textContent='📷 Processing photo…'; }catch(e){}
// iOS FREEZE FIX (DTD verdict B): do ZERO client-side decode. FileReader reads the raw file to a
// data URL without ever decoding the 12MP image into a bitmap/canvas — the server downscales+converts.
const url=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
if(!url){ try{ $('#fbNote').textContent='Could not read that photo — try again.'; }catch(e){} return; }
if(isBack){ _fbBack=url; const t=$('#fbBackThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbBackTile').classList.add('set');
$('#fbNote').textContent='Reading label + QR…'; _fbQR=await decodeQR(url).catch(()=>null); $('#fbNote').textContent=_fbQR?'🔗 QR code read':''; }
else { _fbFront=url; const t=$('#fbFrontThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbFrontTile').classList.add('set'); }
$('#fbGo').disabled=!(_fbBack||_fbFront);
// LOOK-UP SKU flow: front first → prompt/open the BACK (label) → then auto-identify
if(_fbLookup){
// NOTE: do NOT programmatically re-open the native camera here — iOS Safari LOCKS UP when a
// capture-input .click() fires from a change-event (not a fresh user gesture). Prompt a tap instead.
if(!isBack && !_fbBack){ $('#fbFrontTile').classList.remove('need'); $('#fbBackTile').classList.add('need');
$('#fbNote').innerHTML='✓ Front captured — <b>tap “Back · label” below to shoot the label</b> (or 🔎 Identify to look up by pattern only)';
$('#fbGo').disabled=false; } // allow front-only identify too
else if(isBack){ $('#fbBackTile').classList.remove('need'); fbIdentify(); } // both in → look it up
}
}
function fbVisualHtml(vis){ if(!vis||!vis.length) return ''; return '<div class="samp-meta" style="margin-top:12px">Visual matches:</div>'+vis.slice(0,4).map(v=>`<div class="fb-hit">${v.image?`<img src="${v.image}">`:''}<div><b>${v.pattern||v.dw_sku||''}</b> <small>${Math.round((v.score||0)*100)}%</small><br><small>${[v.vendor,v.dw_sku].filter(Boolean).join(' · ')}</small></div></div>`).join(''); }
// POST JSON with real UPLOAD progress (fetch can't report it) — so a big raw-photo upload on cellular
// shows '⬆ Uploading… n%' instead of a dead-looking wait the user misreads as a freeze.
function postJSONup(url,obj,onUp){ return new Promise((resolve,reject)=>{
const xhr=new XMLHttpRequest(); xhr.open('POST',url); xhr.setRequestHeader('Content-Type','application/json');
if(onUp&&xhr.upload) xhr.upload.onprogress=e=>{ if(e.lengthComputable) onUp(Math.round(e.loaded/e.total*100)); };
xhr.onload=()=>{ try{ resolve(JSON.parse(xhr.responseText)); }catch(e){ reject(e); } };
xhr.onerror=()=>reject(new Error('network')); xhr.ontimeout=()=>reject(new Error('timeout')); xhr.timeout=90000;
xhr.send(JSON.stringify(obj)); }); }
async function fbIdentify(){
$('#fbGo').disabled=true; $('#fbNote').textContent='⬆ Uploading photo…'; $('#fbResult').innerHTML='';
try{
const r=await postJSONup('/api/identify-multi',{back:_fbBack,front:_fbFront,qr:_fbQR},
pct=>{ $('#fbNote').textContent = pct<100 ? `⬆ Uploading photo… ${pct}%` : '🔎 Identifying…'; });
// what the OCR actually read off the label (codes with a digit) — the model# is what runs down the SKU
const ocrCands=(r.back_read&&r.back_read.candidates)||[];
const ocrTop=r.code||(r.back_read&&r.back_read.top)||ocrCands[0]||'';
const engLbl=(r.back_read&&r.back_read.engines&&r.back_read.engines.join('+'))||'';
const readLine=ocrCands.length?`<div class="fb-read">📄 read${engLbl?` (${engLbl})`:''}: ${ocrCands.slice(0,6).join(' · ')}</div>`:'';
if(!r.found){
if(ocrTop){ $('#fbNote').innerHTML=`No exact match for <b>${ocrTop}</b> — searching the wallpaper file…`+readLine; wallpaperSearch(ocrTop); }
else { $('#fbNote').innerHTML='No code read — try a clearer/closer photo of the label, or type part of the SKU below.'+readLine; $('#fbResult').innerHTML=fbVisualHtml(r.visual); }
$('#fbGo').disabled=false; return; }
const p=r.primary, conf=(r.confidence||'').replace('-','');
const via=r.source==='qr'?'🔗 QR':r.source==='code'?'📄 label code':'🎨 visual';
$('#fbNote').innerHTML=`Source: ${via}${r.corroborated?' · ✓ front+back agree':''}`+readLine;
$('#fbResult').innerHTML=`<div class="fb-hit">${p.image?`<img src="${p.image}">`:''}<div><b>${p.pattern||p.mfr_sku||p.dw_sku||'—'}</b><span class="fb-conf ${conf}">${r.confidence}</span><br><small>${[p.vendor,p.dw_sku,p.mfr_code].filter(Boolean).join(' · ')}</small></div></div>`+`<div id="fbWpDetails"></div>`+fbVisualHtml(r.visual);
// WALLPAPER lookup: trust a CODE/QR identify; but a VISUAL-only match doesn't beat the printed model# —
// so prefer the OCR'd label code for the file lookup, falling back to the identified code.
const byCode=(r.source==='code'||r.source==='qr');
// Always key the FM lookup off the PRINTED code (ocrTop) first — that's the model# on the label —
// plus the resolved dw_sku when we code-matched. The endpoint tries every dashed/dashless form.
loadWallpaperDetails({ dw_sku: byCode?p.dw_sku:'', mfr_code: ocrTop||p.mfr_code||p.mfr_sku, _ocr: ocrTop });
if(!_fbLookup){ matchIncoming(p); checkSampleRequest(p.dw_sku||p.mfr_code, p.mfr_code); } // Check-Sample-In: flag the incoming match + print popup; pure Look-Up SKU skips both
}catch(e){ $('#fbNote').textContent='Failed: '+e.message; }
$('#fbGo').disabled=false;
}
// On any scan, pull the master product record from the WALLPAPER FileMaker file and show its details.
async function loadWallpaperDetails(p){ const box=$('#fbWpDetails')||$('#fbResult'); if(!box) return;
const sku=encodeURIComponent(p.dw_sku||p.internal_sku||''), code=encodeURIComponent(p.mfr_code||p.mfr_sku||'');
if(!sku && !code) return;
box.innerHTML='<div class="wp-d-hd">Wallpaper file…</div>';
let r; try{ r=await(await fetch(`/api/wallpaper-details?sku=${sku}&code=${code}`)).json(); }catch(e){ box.innerHTML=''; return; }
if(!r||!r.ok||!r.found||!r.details){ // exact miss → try a partial/fuzzy lookup
box.innerHTML='<div class="wp-d-hd wp-d-empty">No exact wallpaper-file record — searching partial…</div>';
wallpaperSearch(p.mfr_code||p.mfr_sku||p.dw_sku||p._ocr||'', box); return; }
const d=r.details;
const rows=[['Mfr #','mfr_pattern'],['Collection','series'],['Width','width'],['Repeat','repeat'],
['Retail','retail'],['Net','net'],['Cost','cost'],['Roll','roll_size'],['Minimum','minimum'],
['Border','border'],['Price code','five_ten'],['Line','line'],['SKU','combo_sku']]
.filter(([,k])=>d[k]).map(([lbl,k])=>{
let v=d[k]; if((k==='retail'||k==='net'||k==='cost')&&/^\d/.test(v)) v='$'+v;
return `<div class="wp-d-row"><span class="wp-d-lbl">${lbl}</span><span class="wp-d-val">${v}</span></div>`; });
const desc=d.internal_desc?`<div class="wp-d-desc">${d.internal_desc}</div>`:'';
box.innerHTML=`<div class="wp-d-card"><div class="wp-d-hd">📒 Wallpaper file${r.matched>1?` · ${r.matched} records`:''}</div><div class="wp-d-grid">${rows.join('')}</div>${desc}</div>`;
}
// Partial / fuzzy search of the WALLPAPER file by any part of a SKU or description. Renders a candidate
// list into `box` (defaults to #fbResult for the manual search box).
let _wpSeq=0;
async function wallpaperSearch(q, box){ box=box||$('#fbResult'); if(!box) return;
q=(q||'').trim(); if(q.replace(/[^A-Za-z0-9]/g,'').length<3){ box.innerHTML='<div class="wp-d-hd wp-d-empty">Type at least 3 characters.</div>'; return; }
const seq=++_wpSeq; // race guard: only the latest query renders
box.innerHTML=`<div class="wp-d-hd">Searching the wallpaper file for “${q}”…</div>`;
let r; try{ r=await(await fetch('/api/wallpaper-search?q='+encodeURIComponent(q))).json(); }catch(e){ if(seq===_wpSeq) box.innerHTML='<div class="wp-d-hd wp-d-empty">Search failed.</div>'; return; }
if(seq!==_wpSeq) return; // a newer keystroke already superseded this
const cands=(r&&r.candidates)||[];
if(!cands.length){ box.innerHTML=`<div class="wp-d-hd wp-d-empty">No wallpaper-file match for “${q}”.</div>`; return; }
const rowHtml=c=>{ const bits=[c.width&&('W '+c.width),c.repeat&&('R '+c.repeat),c.retail&&('$'+c.retail),c.supplier,c.line].filter(Boolean).join(' · ');
return `<div class="wp-c" onclick="loadWallpaperDetailsBySku('${(c.combo_sku||'').replace(/'/g,'')}','${(c.mfr_pattern||'').replace(/'/g,'')}')">
<div class="wp-c-top"><b>${c.mfr_pattern||c.combo_sku||'—'}</b><span class="wp-c-sku">${c.combo_sku||''}</span></div>
<div class="wp-c-sub">${[c.series,bits].filter(Boolean).join(' · ')||c.internal_desc||''}</div></div>`; };
box.innerHTML=`<div class="wp-d-hd">📒 ${cands.length} partial match${cands.length>1?'es':''} for “${q}”</div><div class="wp-c-list">${cands.map(rowHtml).join('')}</div>`;
}
// tapping a candidate loads its full detail card
async function loadWallpaperDetailsBySku(sku,code){ await loadWallpaperDetails({dw_sku:sku,mfr_code:code}); }
// INCOMING SAMPLES (Check Sample In): show the ordered-not-received queue with image+spec; on a scan, flag the match.
let _incoming=[];
async function loadIncoming(){ const box=$('#fbIncoming'); if(!box) return; box.innerHTML='<div class="inc-hd">Loading samples coming in…</div>';
let r; try{ r=await(await fetch('/api/incoming-samples')).json(); }catch(e){ box.innerHTML=''; return; }
_incoming=(r&&r.samples)||[];
if(!_incoming.length){ box.innerHTML=r&&r.ok?'<div class="inc-hd">No samples currently marked ordered-not-received.</div>':''; return; }
box.innerHTML='<div class="inc-hd"><b>📦 '+_incoming.length+' coming in</b> — ordered, not yet received. Scan an arrival to check it in.</div>'
+'<div class="inc-strip">'+_incoming.map((s,i)=>`<div class="inc-card" data-i="${i}" data-rid="${s.recordId||''}" data-sku="${(s.dw_sku||'').toUpperCase()}" data-mfr="${(s.mfr_sku||s.mfr_pattern||'').toUpperCase().replace(/[^A-Z0-9]/g,'')}"><div class="inc-thumb"${s.image?` style="background-image:url('${s.image}')"`:''}>${s.image?'':'📦'}</div><div class="inc-meta"><div class="inc-pat">${(s.pattern||s.mfr_pattern||s.combo_sku||'—')}</div><div class="inc-sub">${[s.vendor,s.vendor_ordered].filter(Boolean).join(' · ')}</div><button class="inc-recv" onclick="event.stopPropagation();markReceived('${s.recordId||''}',this)">✓ Received</button></div></div>`).join('')+'</div>';
box.querySelectorAll('.inc-card').forEach(c=>c.addEventListener('click',()=>{ const s=_incoming[+c.dataset.i]; $('#fbNote').innerHTML=`📦 <b>${s.pattern||s.mfr_pattern||s.combo_sku}</b> — ${[s.vendor,s.vendor_ordered&&('ordered '+s.vendor_ordered),s.client&&('for '+s.client)].filter(Boolean).join(' · ')}`; }));
}
// Check a scanned/arrived sample IN → stamps Date WP Sample Sent=today in FileMaker, which also removes
// it from the incoming queue. `el` is the ✓ button (card-level) or the scan-match button (in fbNote).
async function markReceived(rid,el){ if(!rid){ return; } const card=el&&el.closest?el.closest('.inc-card'):null;
if(el){ el.disabled=true; el.textContent='…'; }
let r; try{ r=await(await fetch('/api/mark-received',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({recordId:rid})})).json(); }catch(e){ r={ok:false,err:'network'}; }
if(r&&r.ok){ if(card){ card.style.transition='opacity .3s'; card.style.opacity='0'; setTimeout(()=>card.remove(),300); } _incoming=_incoming.filter(s=>String(s.recordId)!==String(rid));
const hd=document.querySelector('#fbIncoming .inc-hd'); if(hd) hd.innerHTML='<b>📦 '+_incoming.length+' coming in</b> — ordered, not yet received. Scan an arrival to check it in.';
$('#fbNote').innerHTML=`✅ <b style="color:#2bd06a">Checked in — received ${r.received}</b>`;
} else if(el){ el.disabled=false; el.textContent='✓ Received'; $('#fbNote').innerHTML=`⚠️ Couldn't check in: ${(r&&r.err)||'error'}`; } }
function matchIncoming(p){ const pk=(p.dw_sku||'').toUpperCase(), pm=(p.mfr_sku||p.mfr_code||'').toUpperCase().replace(/[^A-Z0-9]/g,''); let hit=null;
document.querySelectorAll('#fbIncoming .inc-card').forEach(c=>{ const m=(pk&&c.dataset.sku===pk)||(pm&&c.dataset.mfr&&(c.dataset.mfr===pm||c.dataset.mfr.startsWith(pm))); c.classList.toggle('hit',!!m); if(m){ hit=c; c.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'}); } });
if(hit){ const rid=hit.dataset.rid||''; $('#fbNote').innerHTML+=' · <b style="color:#2bd06a">✓ this is an incoming sample</b>'
+(rid?` <button class="inc-recv" style="margin-left:8px" onclick="markReceived('${rid}',this)">✓ Check in — received today</button>`:''); } }
function openFbModal(){ _fbBack=_fbFront=_fbQR=null; _fbLookup=false; $('#fbBackThumb').style.backgroundImage=''; $('#fbBackThumb').textContent='📄'; $('#fbFrontThumb').style.backgroundImage=''; $('#fbFrontThumb').textContent='🎨'; $('#fbBackTile').classList.remove('set','need'); $('#fbFrontTile').classList.remove('set','need'); $('#fbGo').disabled=true; $('#fbNote').textContent=''; $('#fbResult').innerHTML=''; $('#fbIncoming').innerHTML='';
$('#fbModal').querySelector('.samp-pat').textContent='Identify a sample';
$('#fbModal').querySelector('.samp-meta').textContent='Photograph the BACK label, the FRONT pattern, or both — best ID uses both.';
$('#fbModal').hidden=false; document.body.style.overflow='hidden'; }
// LOOK UP SKU: camera-first — shoot the FRONT pattern, then it prompts/opens the BACK label, then IDs.
function openLookup(){ openFbModal(); _fbLookup=true;
$('#fbModal').querySelector('.samp-pat').textContent='Look up SKU';
$('#fbModal').querySelector('.samp-meta').textContent='Shoot the FRONT pattern first — then the BACK label (the text/SKU) — and it identifies the item.';
$('#fbFrontTile').classList.add('need');
setTimeout(()=>{ try{ $('#fbFrontInput').click(); }catch(e){} }, 250); } // camera on, front first
$('#fbBtn').addEventListener('click',openFbModal);
$('#fbClose').addEventListener('click',()=>{ $('#fbModal').hidden=true; document.body.style.overflow=''; });
$('#fbBackTile').addEventListener('click',()=>$('#fbBackInput').click());
$('#fbFrontTile').addEventListener('click',()=>$('#fbFrontInput').click());
$('#fbBackInput').addEventListener('change',e=>fbRead(e.target.files[0],true));
$('#fbFrontInput').addEventListener('change',e=>fbRead(e.target.files[0],false));
$('#fbGo').addEventListener('click',fbIdentify);
$('#fbSearchBtn').addEventListener('click',()=>wallpaperSearch($('#fbSearchInput').value));
$('#fbSearchInput').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); clearTimeout(_wpSearchT); wallpaperSearch(e.target.value); } });
// live search-as-you-type: debounced so we don't fire a FileMaker scan on every keystroke
let _wpSearchT=null;
$('#fbSearchInput').addEventListener('input',e=>{ const v=e.target.value; clearTimeout(_wpSearchT);
if(v.replace(/[^A-Za-z0-9]/g,'').length<3){ if(!v) $('#fbResult').innerHTML=''; return; }
_wpSearchT=setTimeout(()=>wallpaperSearch(v),300); });
// ── Add / Update item: capture up to 10 photos + 6 videos → Shopify (draft-create OR live-update) ──
const MAX_PHOTOS=10, MAX_VIDEOS=6;
let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
let _media=[]; // {type:'photo'|'video', dataUrl, name}
let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false, _addSession=0, _updateNeedsConfirm=false;
function mediaCounts(){ return { p:_media.filter(m=>m.type==='photo').length, v:_media.filter(m=>m.type==='video').length }; }
function renderMedia(){ const c=mediaCounts();
$('#cPhoto').textContent=c.p+'/'+MAX_PHOTOS; $('#cVideo').textContent=c.v+'/'+MAX_VIDEOS;
$('#addPhotoBtn').disabled=c.p>=MAX_PHOTOS; $('#addVideoBtn').disabled=c.v>=MAX_VIDEOS;
$('#addMedia').innerHTML=_media.map((m,i)=> m.type==='video'
? `<div class="media-cell"><video src="${m.dataUrl}" muted playsinline preload="metadata"></video><span class="badge">🎥</span><button class="rm" data-rm="${i}">✕</button></div>`
: `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
$('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); }
function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again or use a JPEG'); return; }
_media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
if(!_extracted){ _extracted=true; addExtract(); } // first photo = the scan → read specs
} else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
const dataUrl=await fileToDataUrl(file); _media.push({type:'video',dataUrl,name:file.name||'clip.mp4'}); renderMedia(); } }
async function loadVendors(){ if(_vendorsLoaded) return; try{ const r=await(await fetch('/api/vendors')).json(); $('#addVendor').innerHTML='<option value="">— pick vendor —</option>'+(r.vendors||[]).map(v=>`<option value="${v.vendor}" data-vid="${v.vid||''}">${v.vendor}${v.vid?(' ('+v.vid+')'):''}</option>`).join(''); _vendorsLoaded=true; }catch(e){} }
function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; addResetMedia();
['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
$('#addTitle').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
$('#addTarget').textContent = _addMode==='update' ? 'Shoot the label to find the SKU, then add photos + videos.' : '';
$('#addPreview').textContent = _addMode==='update' ? '⬆ Push media to SKU (LIVE)' : '👁 Preview';
if(pref){ if(pref.mfr)$('#addMfr').value=pref.mfr; if(pref.color)$('#addColor').value=pref.color; if(pref.name)$('#addName').value=pref.name; }
$('#addModal').hidden=false; document.body.style.overflow='hidden';
loadVendors().then(()=>{ if(pref&&pref.vendor) $('#addVendor').value=pref.vendor; });
if(opts.camera) setTimeout(()=>$('#addPhotoInput').click(),250); // onload → go straight to camera
}
function addPhotosList(){ return _media.filter(m=>m.type==='photo').map(m=>m.dataUrl); }
function addVideosList(){ return _media.filter(m=>m.type==='video'); }
function addPayload(commit){ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:_addPhoto, photos:addPhotosList(), commit:!!commit }); }
async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0,failed=0;
for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; else failed++; }catch(e){ failed++; } }
return { ok, failed }; }
// Shopify transcodes video async — poll until every clip is READY (or a couple minutes pass).
async function pollVideoStatus(product_id, baseMsg, expected){ const started=Date.now(); expected=expected||1;
while(Date.now()-started < 150000){
await new Promise(r=>setTimeout(r,5000));
let s; try{ s=await(await fetch('/api/media-status?product_id='+encodeURIComponent(product_id))).json(); }catch(e){ continue; }
if(!s || !s.ok) continue;
if(s.failed){ $('#addNote').innerHTML=`${baseMsg} · ⚠ ${s.failed} video(s) failed to process`; return; }
// don't declare "ready" until ALL expected clips have registered AND finished (a 2nd clip can lag)
if(s.all_ready && s.total>=expected){ $('#addNote').innerHTML=`${baseMsg} · 🎥 ${s.ready} video(s) ready ✓`; toast('🎥 Videos ready on Shopify'); return; }
$('#addNote').innerHTML=`${baseMsg} · 🎥 transcoding ${s.ready}/${Math.max(s.total,expected)}…`;
}
$('#addNote').innerHTML=`${baseMsg} · 🎥 still transcoding — check Shopify shortly.`;
}
async function addPreview(){ if(_addMode==='update') return addUpdate();
const p=addPayload(false); if(!p.mfr||!p.vendor){ $('#addNote').textContent='Mfr# and Vendor are required.'; return; }
$('#addNote').textContent='Previewing…'; $('#addResult').innerHTML='';
const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)})).json();
if(r.duplicate){ $('#addNote').innerHTML='⚠ '+r.err+' — tap <b>⌂ → Update SKU</b> to add media to it live.'; return; }
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); return; }
const pv=r.preview, c=mediaCounts(); $('#addNote').textContent='Preview — nothing created yet.';
$('#addResult').innerHTML=`<div class="fb-hit"><div><b>${pv.title}</b><br><small>${[pv.vendor,pv.dw_sku,pv.mfr,pv.color,pv.price?('$'+pv.price):''].filter(Boolean).join(' · ')} · ${c.p} photo(s) · ${c.v} video(s) · <b>DRAFT</b></small></div></div><button class="samp-print" id="addCommit" style="margin-top:10px">✅ Create draft + upload media</button>`;
$('#addCommit').addEventListener('click',addCommit);
}
async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
$('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
}
// UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
// resolve an mfr# / sku to a live Shopify product_id and bind it as the Update target
// confirmed=true only for EXPLICIT binds (label OCR match, or a user tapping a candidate).
// A visual auto-bind is confirmed=false → addUpdate demands a 2nd Push before writing LIVE.
async function bindProduct(mfr,sku,confirmed){ const qs=mfr?('mfr='+encodeURIComponent(mfr)):('sku='+encodeURIComponent(sku||''));
try{ const r=await(await fetch('/api/resolve-product?'+qs)).json();
if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; _updateNeedsConfirm=!confirmed; return r; } }catch(e){} return null; }
// ADD-MODE DEDUP: before creating a new SKU, check the pattern photo against the catalog (fine-tuned
// visual-ID). If it strongly matches an existing product, WARN (non-blocking) + offer "Update instead".
async function addDedupCheck(){ const mySession=_addSession; // guard against a stale result landing in a reopened modal
const photos=addPhotosList(); if(!photos.length || _addMode!=='add') return;
let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fronts:photos})})).json(); }catch(e){ return; }
const prim=r&&r.primary; if(!prim || !r.confidence || r.confidence==='low') return; // only warn on a confident match
if(_addMode!=='add' || _addSession!==mySession) return; // modal switched/reopened mid-request — discard
const label=prim.pattern || prim.dw_sku || prim.mfr_sku || 'an existing item';
// WARN using the visual match itself (the pattern exists in the catalog even if it doesn't map to a live product_id)
$('#addTarget').innerHTML=`⚠ This pattern may already exist as <b>${label}</b>${prim.dw_sku?(' <small>('+prim.dw_sku+')</small>'):''} <small>· ${r.confidence}</small>`;
// resolve to a LIVE product (mfr then dw_sku) — REQUIRE a real product_id, else no "Update" shortcut
let hit=null;
for(const qs of [prim.mfr_sku&&('mfr='+encodeURIComponent(prim.mfr_sku)), prim.dw_sku&&('sku='+encodeURIComponent(prim.dw_sku))].filter(Boolean)){
try{ const h=await(await fetch('/api/resolve-product?'+qs)).json(); if(h.found && h.product_id){ hit=h; break; } }catch(e){} }
if(_addMode!=='add' || _addSession!==mySession) return; // re-check after the resolve awaits
if(hit){ $('#addTarget').innerHTML+=` <button class="vid-chip" id="addSwitchUpd">↻ Update it instead</button>`;
const el=document.getElementById('addSwitchUpd'); if(el) el.addEventListener('click',()=>switchToUpdate(hit)); } }
// user chose to update the existing match instead of creating a dup — bind it (explicit → no extra confirm)
function switchToUpdate(hit){ _addMode='update'; _updatePid=hit.product_id; _updateSku=hit.dw_sku; _updateNeedsConfirm=false;
$('#addTitle').textContent='Update SKU on Shopify'; $('#addPreview').textContent='⬆ Push media to SKU (LIVE)';
$('#addTarget').innerHTML=`✓ Updating <b>${hit.title||hit.dw_sku}</b> <small>(${hit.dw_sku}) — your photos will attach here</small>`;
$('#addResult').innerHTML=''; toast('Switched to Update — media attaches to '+hit.dw_sku); }
async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
const r=await bindProduct(mfr,null,true); // label OCR match = explicit → no extra confirm needed
if(r){ $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No SKU matched “'+mfr+'”. Try a photo of the pattern, fix the mfr#, or use Add New SKU.'; } }
// REALLY ID by DESIGN: front pattern (+ label) photo → /api/identify-multi (OCR+QR+vendor+CLIP visual)
// → bind the matched product. Lets Update mode find a SKU even when the label is unreadable.
async function visualIdentify(){ const photos=addPhotosList(); if(!photos.length) return;
$('#addTarget').innerHTML='🔍 Identifying by pattern…';
let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({back:photos[0], fronts:photos})})).json(); }catch(e){ $('#addTarget').textContent='Visual ID failed.'; return; }
const cands=(r.visual||[]).slice(0,4), prim=r.primary; let bound=null;
// AUTO-bind ONLY a confident primary. Low-confidence or candidate matches require an explicit
// tap — never silently bind a live customer product off an ambiguous pattern photo.
if(prim && r.confidence && r.confidence!=='low') bound=await bindProduct(prim.mfr_sku, prim.dw_sku);
if(bound){ if(prim.mfr_sku&&!$('#addMfr').value)$('#addMfr').value=prim.mfr_sku;
$('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence} · ${_updateSku})</small>`; }
else if(cands.length){ _vidCands=cands;
$('#addTarget').innerHTML='🔍 Closest patterns — <b>tap to confirm</b>:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku,true); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
else $('#addTarget').innerHTML='No confident match — fix the mfr# or use Add New SKU.'; }
let _vidCands=[];
async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
if(!_updatePid) await visualIdentify(); // last resort: match by the pattern itself
if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label/pattern or fix the mfr#.'; return; }
// INVARIANT: never write LIVE to a product bound by an ambiguous visual match without an explicit
// 2nd Push — covers BOTH the mid-Push bind AND the background bind set earlier during addExtract.
if(_updateNeedsConfirm){ _updateNeedsConfirm=false;
$('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; }
const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
$('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
const hadVideos=addVideosList().length;
const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
$('#addNote').innerHTML=baseMsg; toast('✅ Updated '+_updateSku+' live');
if(vup.ok) pollVideoStatus(_updatePid, baseMsg, vup.ok);
}
$('#addBtn').addEventListener('click',()=>openAddModal());
$('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
$('#addVendor').addEventListener('change',e=>{ const o=e.target.selectedOptions[0]; if(o&&o.dataset.vid&&!$('#addVid').value) $('#addVid').value=o.dataset.vid; });
$('#addPhotoBtn').addEventListener('click',()=>$('#addPhotoInput').click());
$('#addVideoBtn').addEventListener('click',()=>$('#addVideoInput').click());
$('#addPhotoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('photo',f)); });
$('#addVideoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('video',f)); });
$('#addPreview').addEventListener('click',addPreview);
// import AS MUCH INFO AS POSSIBLE from the label photo → prefill every field, then VOICE ONLY as
// needed: if a required field is still blank, the app speaks the prompt + listens (dropdown stays).
async function addExtract(){ if(!_addPhoto)return; const mySession=_addSession; $('#addNote').textContent='📷 Reading label…';
try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:_addPhoto})})).json();
if(_addSession!==mySession) return; // modal was closed/reopened mid-request — discard this stale result
const f=r.fields||{}; _addExtracted=f; const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
if(r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===r.vendor_matched.toLowerCase()); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; } }
const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
$('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
$('#addNote').textContent='✓ Imported from label';
if(_addMode==='update'){ await addResolveUpdate(); if(_addSession!==mySession) return; // discard if modal reset mid-resolve
if(!_updatePid){ await visualIdentify(); if(_addSession!==mySession) return; } } // label first, then match by design
else { addPromptMissing(); addDedupCheck(); } // warn (non-blocking) if this pattern already exists
}catch(e){ $('#addNote').textContent=''; }
}
// speak a prompt out loud, then listen for the answer (the on-screen field stays usable too)
function speakThenListen(prompt, onResult){
const go=()=>{ const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR)return; const r=new SR(); r.lang='en-US'; r.interimResults=false; r.onresult=e=>onResult(((e.results[0][0]||{}).transcript||'').trim()); r.onerror=()=>{}; try{ r.start(); }catch(e){} };
try{ const u=new SpeechSynthesisUtterance(prompt); u.rate=1.05; u.onend=go; speechSynthesis.cancel(); speechSynthesis.speak(u); setTimeout(()=>{ if(speechSynthesis.speaking)return; go(); }, 2500); }catch(e){ go(); }
}
// voice ONLY as needed — ask for the first missing REQUIRED field (mfr, then vendor)
function addPromptMissing(){
if(!$('#addMfr').value.trim()){ $('#addMfr').classList.add('need'); $('#addNote').innerHTML='🔊 <b>Enter the SKU number</b> — say it or type it';
return speakThenListen('Enter the S K U number', t=>{ const c=(t.toUpperCase().match(/[A-Z]{1,4}[- ]?\d{2,6}/)||t.match(/\d{3,6}/)||[])[0]; if(c){ $('#addMfr').value=c.replace(/\s/g,''); $('#addMfr').classList.remove('need'); } $('#addNote').textContent='🎤 “'+t+'”'; addPromptMissing(); }); }
if(!$('#addVendor').value){ $('#addVendor').classList.add('need'); $('#addNote').innerHTML='🔊 <b>Enter vendor name</b> — say it or pick from the list';
return speakThenListen('Enter vendor name', async t=>{ await loadVendors(); const up=t.toUpperCase(); const opt=[...$('#addVendor').options].find(o=>o.value&&(o.value.toUpperCase().includes(up.split(' ')[0])||up.includes(o.value.toUpperCase()))); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; $('#addVendor').classList.remove('need'); } $('#addNote').textContent='🎤 “'+t+'”'; }); }
$('#addNote').textContent='✓ Ready — Preview';
}
$('#addVoice').addEventListener('click',addPromptMissing); // manual re-trigger of the voice fill
// ── Live dimension measure: a credit card (3.375" wide) in frame calibrates pixels→inches ──
const CARD_IN=3.375; // ISO/IEC 7810 ID-1 (credit card) width
let _mStream=null,_measW=0,_measL=0,_ppi=0,_mMode='measure';
// CALIBRATE ONCE: a card sizes px→inches for THIS device; stored in localStorage, reused forever.
function calGet(){ try{ return JSON.parse(localStorage.getItem('dwMeasureCal')||'null'); }catch(e){ return null; } }
function calSet(o){ try{ localStorage.setItem('dwMeasureCal',JSON.stringify(o)); }catch(e){} }
function mSetMode(mode){ _mMode=mode;
$('#mCard').hidden = mode!=='calibrate'; // card only shown while calibrating
$('#mSample').hidden = mode==='calibrate';
$('#mSaveCal').hidden = mode!=='calibrate';
$('#mUse').hidden = mode==='calibrate';
$('#mRecal').hidden = mode==='calibrate';
mRecompute(); }
function mRecompute(){
if(_mMode==='calibrate'){ _ppi=$('#mCard').offsetWidth/CARD_IN;
$('#mReadout').innerHTML=`One-time setup: size 💳 to a real credit card, then Save. <small>(${_ppi.toFixed(0)} px/in)</small>`; return; }
const s=$('#mSample'); if(!_ppi){ $('#mReadout').textContent='Tap ↺ Recalibrate first.'; return; }
_measW=s.offsetWidth/_ppi; _measL=s.offsetHeight/_ppi;
$('#mReadout').innerHTML=`W <b>${_measW.toFixed(1)}"</b> · L <b>${_measL.toFixed(1)}"</b> <small>· saved calibration · hold at your usual distance</small>`; }
function mMakeBox(el){ let mode=null,sx,sy,ox,oy,ow,oh; const rz=el.querySelector('.mrz');
const start=(e,m)=>{ mode=m; sx=e.clientX; sy=e.clientY; ox=el.offsetLeft; oy=el.offsetTop; ow=el.offsetWidth; oh=el.offsetHeight; (m==='resize'?rz:el).setPointerCapture(e.pointerId); e.preventDefault(); e.stopPropagation(); };
const move=e=>{ if(!mode)return; const dx=e.clientX-sx,dy=e.clientY-sy;
if(mode==='move'){ el.style.left=(ox+dx)+'px'; el.style.top=(oy+dy)+'px'; }
else { el.style.width=Math.max(34,ow+dx)+'px'; el.style.height=Math.max(34,oh+dy)+'px'; } mRecompute(); };
const end=()=>{ mode=null; };
el.addEventListener('pointerdown',e=>{ if(e.target!==rz) start(e,'move'); });
rz.addEventListener('pointerdown',e=>start(e,'resize'));
el.addEventListener('pointermove',move); rz.addEventListener('pointermove',move);
el.addEventListener('pointerup',end); rz.addEventListener('pointerup',end); }
async function openMeasure(){
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
toast('📷 Live camera needs Safari on iPhone — Chrome/other apps block it. Open in Safari.'); return; }
try{ _mStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
catch(e){ toast('Camera blocked — allow camera access, or open in Safari.'); return; }
// LOCK ZOOM to minimum so the focal length / field-of-view stays constant (scale depends on it)
try{ const tr=_mStream.getVideoTracks()[0], caps=tr.getCapabilities?tr.getCapabilities():{};
if(caps.zoom) await tr.applyConstraints({advanced:[{zoom:caps.zoom.min||1}]}); }catch(e){}
$('#mVideo').srcObject=_mStream; $('#measureModal').hidden=false; document.body.style.overflow='hidden';
Object.assign($('#mCard').style,{left:'26px',top:'96px',width:'135px',height:'85px'});
Object.assign($('#mSample').style,{left:'56px',top:'200px',width:'210px',height:'270px'});
const cal=calGet();
// reuse the saved calibration only if the viewport matches — ppi is in CSS px, so a different
// screen width (rotation / different device) would make the stored ppi wrong.
if(cal&&cal.ppi&&(!cal.sw||Math.abs(cal.sw-window.innerWidth)<=2)){ _ppi=cal.ppi; mSetMode('measure'); }
else { if(cal&&cal.ppi) toast('Screen changed — quick one-time recalibrate'); mSetMode('calibrate'); } } // first time on this device → calibrate
function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
mMakeBox($('#mCard')); mMakeBox($('#mSample'));
$('#addMeasure').addEventListener('click',openMeasure);
$('#mClose').addEventListener('click',closeMeasure);
$('#mRecal').addEventListener('click',()=>mSetMode('calibrate'));
$('#mSaveCal').addEventListener('click',()=>{ mRecompute();
if(_ppi>0){ calSet({ppi:_ppi, sw:window.innerWidth, ts:Date.now()});
toast('✓ Calibrated ('+_ppi.toFixed(0)+' px/in) — no card needed next time'); mSetMode('measure'); } });
$('#mUse').addEventListener('click',()=>{ const w=_measW.toFixed(1)+'"',l=_measL.toFixed(1)+'"';
_addExtracted.width=w; _addExtracted.roll_length=l;
$('#addSpecs').insertAdjacentHTML('afterbegin',`<div class="add-spec"><span>📐 Measured</span><b>${w} × ${l}</b></div>`);
toast(`📐 ${w} × ${l} saved`); closeMeasure(); });
$('#addMfr').addEventListener('input',()=>{ $('#addMfr').classList.remove('need');
// editing the mfr# unbinds any resolved Update target so we never push to a stale/wrong product
if(_addMode==='update' && _updatePid){ _updatePid=null; _updateSku=null; $('#addTarget').textContent='mfr# changed — re-scan or press Push to re-find the SKU.'; } });
$('#addVendor').addEventListener('change',()=>$('#addVendor').classList.remove('need'));
// Per-field mic: tap the 🎤 on any field and speak straight into THAT field. kind tunes the parse.
let _micLive=null;
function dictateInto(fieldId, kind, btn){
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SR){ toast('Voice not supported here — use your keyboard’s mic 🎙️'); $('#'+fieldId).focus(); return; }
if(_micLive){ try{_micLive.stop();}catch(e){} } // only one mic at a time
const r=new SR(); _micLive=r; r.lang='en-US'; r.interimResults=false;
const off=()=>{ btn.classList.remove('on'); _micLive=null; };
btn.classList.add('on'); toast('🎤 Listening…');
r.onend=off; r.onerror=off;
r.onresult=async e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(!t)return;
const el=$('#'+fieldId);
if(kind==='vendor'){ await loadVendors(); const up=t.toUpperCase(); const opt=[...el.options].find(o=>o.value&&(o.value.toUpperCase().includes(up.split(' ')[0])||up.includes(o.value.toUpperCase())));
if(opt){ el.value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; el.classList.remove('need'); toast('🎤 “'+t+'” → '+opt.value); } else toast('No vendor match for “'+t+'”'); }
else if(kind==='sku'){ const c=(t.toUpperCase().match(/[A-Z]{1,4}[- ]?\d{2,6}/)||t.match(/\d{3,6}/)||[])[0]; el.value=(c||t).toUpperCase().replace(/\s/g,''); el.classList.remove('need'); toast('🎤 “'+t+'”'); }
else if(kind==='num'){ const n=(t.match(/[\d.]+/)||[])[0]; if(n)el.value=n; toast('🎤 “'+t+'”'); }
else { el.value=t; el.classList.remove('need'); toast('🎤 “'+t+'”'); }
};
try{ r.start(); }catch(e){ off(); }
}
document.querySelectorAll('#addModal .mic-btn').forEach(b=>b.addEventListener('click',ev=>{ ev.preventDefault(); dictateInto(b.dataset.mic, b.dataset.kind, b); }));
// ── onload chooser: Add New SKU · Update SKU · Check Sample In ──
function routeHome(act){ $('#homeScreen').hidden=true;
// onload → straight to the camera; details show after the first scan
if(act==='add') openAddModal(null,{mode:'add',camera:true});
else if(act==='update') openAddModal(null,{mode:'update',camera:true});
else if(act==='lookup') openLookup(); // camera-first front→back → identify
else if(act==='check'){ openFbModal(); loadIncoming(); } // show the incoming (ordered-not-received) queue first
else if(act==='skip'){ if(!TWILITEMS.length) loadTwil(); } // lazy-load the big TWIL grid only when actually viewing it
}
document.querySelectorAll('#homeScreen .home-pill, #homeScreen .home-skip').forEach(b=>b.addEventListener('click',()=>routeHome(b.dataset.act)));
$('#homeBtn').addEventListener('click',()=>{ $('#homeScreen').hidden=false; });
$('#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
// than OCR (a few seconds) so it's user-tapped — for swatches whose brand text won't OCR.
$('#scanLogo').addEventListener('click',async()=>{
const v=$('#scanvideo'); if(!v.videoWidth){ toast('Camera not ready'); return; }
const s=Math.min(1,768/Math.max(v.videoWidth,v.videoHeight));
const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
const prev=$('#scanStatus').textContent; $('#scanStatus').textContent='🏷 Reading logo…';
try{
const r=await fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:c.toDataURL('image/jpeg',0.8)})});
const d=await r.json();
if(d&&d.brand){
$('#scanStatus').textContent='🏷 '+d.brand+(d.typeface?(' · '+d.typeface):'');
const tag=(d.vendor||d.brand);
// if the logo also revealed a code, run the normal resolve on it
if(d.code){ closeLiveScan(); $('#q').value=tag; applyScan({candidates:[d.code],top:d.code,text:tag,vendor:d.vendor||''}); }
else { toast('🏷 '+tag+(d.code?(' · '+d.code):'')); }
} else { $('#scanStatus').textContent=prev; toast(d&&d.err?('Logo read failed: '+d.err):'Brand not recognized'); }
}catch(e){ $('#scanStatus').textContent=prev; toast('Logo read failed — is Ollama running?'); }
});
// 🎨 Recognize the PATTERN from the live camera frame → similar items (local VLM + DB).
$('#scanPat').addEventListener('click',async()=>{
const v=$('#scanvideo'); if(!v.videoWidth){ toast('Camera not ready'); return; }
const s=Math.min(1,1280/Math.max(v.videoWidth,v.videoHeight));
const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
closeLiveScan();
await recognizeDataUrl(c.toDataURL('image/jpeg',0.85));
});
function syncCamBtn(){ $('#camBtn').textContent=(camFacing==='user'?'🤳 Front':'📷 Back'); }
$('#camBtn').addEventListener('click',flipCam);
$('#micBtn').addEventListener('click',voiceSearch);
$('#scanBtn').addEventListener('click',()=>{
if(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) openLiveScan();
else $('#scanInput').click(); // HTTP (no secure context) → fall back to single-snap
});
$('#scanInput').addEventListener('change',async e=>{
const f=e.target.files[0]; if(!f)return;
collapsed=false;
toast('📷 Reading number…');
const dataUrl=await downscale(f,1600);
try{
const d=await ocrSmart(dataUrl); // front-cam mirror-retry + multi-candidate
e.target.value='';
// multi-pass resolve: every OCR candidate against TWIL sku/mfr/name → fall through to all-Shopify
await applyScan(d, dataUrl);
}catch(err){ toast('Scan failed — check connection'); }
});
// 🎨 Similar — photograph the PATTERN itself (not a label) → recognize + find lookalikes.
$('#simBtn').addEventListener('click',()=>$('#simInput').click());
$('#simInput').addEventListener('change',async e=>{
const f=e.target.files[0]; e.target.value=''; if(!f)return;
collapsed=false; await recognizePattern(f);
});
applyCamCapture(); syncCamBtn(); // apply saved camera choice to capture inputs + header toggle
load(); loadNew(); loadFav(); loadRecent(); // NOTE: loadTwil() (36MB/88k) is now lazy — fires on 'skip'/TWIL view, not page-load (iOS memory)
setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent'&&filter!=='similar') load(); }, 60000);
</script>
</body>
</html>