/** * In-browser powder label scanner for the Inventory Create/Edit forms. * * Flow: * 1. User clicks "Scan Label" → modal opens, rear camera starts (permission asked once * per page load — stream is kept alive between modal opens to avoid re-prompting). * 2. QR scanning uses the native BarcodeDetector API when available (Chrome/Edge/Android), * falling back to jsQR for other browsers. * - QR detected automatically → POST qrUrl to /Inventory/ScanLabel → fill form. * 3. If no QR within ~5 s the "Scan Text" button appears as a fallback. * - User taps it → grab current frame → base64 → POST to /Inventory/ScanLabel → fill form. * 4. Either path: server searches catalog, auto-contributes if new, returns unified data. * 5. Modal closes, form fills using the same field IDs as inventory-catalog-lookup.js. * * Camera permission strategy: * - getUserMedia is called only once per page session (on first open). * - The stream is kept alive when the modal closes so subsequent opens are instant * and the browser does not re-prompt for camera access. * - An idle timer (IDLE_RELEASE_MS) stops the stream after prolonged inactivity so * the camera indicator light doesn't stay on indefinitely. * - The stream is always released on page unload. */ (function () { 'use strict'; const SCAN_URL = '/Inventory/ScanLabel'; const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; const IDLE_RELEASE_MS = 2 * 60 * 1000; // release camera after 2 min idle const scanBtn = document.getElementById('scan-label-btn'); const statusEl = document.getElementById('ai-lookup-status'); if (!scanBtn) return; // Stream is kept alive between modal opens; only released on idle timeout or page unload let stream = null; let rafId = null; let qrFound = false; let shutterTimer = null; let idleTimer = null; // ── Modal elements ──────────────────────────────────────────────────── const modalEl = document.getElementById('labelScanModal'); const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null; const videoEl = document.getElementById('scan-video'); const canvasEl = document.getElementById('scan-canvas'); const scanStatusEl = document.getElementById('scan-modal-status'); const shutterBtn = document.getElementById('scan-shutter-btn'); const shutterWrap = document.getElementById('scan-shutter-wrap'); if (!modalEl || !videoEl || !canvasEl) return; scanBtn.addEventListener('click', openScanner); modalEl.addEventListener('hide.bs.modal', onModalClose); if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); window.addEventListener('beforeunload', releaseCamera); // If the user has already granted camera permission, silently pre-warm the stream // so the next Scan Label click opens instantly without any browser prompt. preWarmCamera(); // ── Open / close ────────────────────────────────────────────────────── async function openScanner() { if (!bsModal) return; qrFound = false; stopQrLoop(); if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (shutterWrap) shutterWrap.classList.add('d-none'); bsModal.show(); if (!stream) { // First open (or after idle release) — request camera access setScanStatus('info', 'Starting camera…'); try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); } catch (err) { setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message); if (shutterWrap) shutterWrap.classList.remove('d-none'); return; } } // Attach (or re-attach) stream to video element if (videoEl.srcObject !== stream) { videoEl.srcObject = stream; } if (videoEl.paused) { try { await videoEl.play(); } catch { /* ignore */ } } setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.'); startQrLoop(); // Reveal the shutter fallback after 5 s in case there is no QR shutterTimer = setTimeout(() => { if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none'); }, 5000); } function onModalClose() { // Stop the scan loop but keep the stream alive to avoid re-prompting on next open stopQrLoop(); // Start idle timer — release camera entirely after 2 min of inactivity idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); } function stopQrLoop() { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; } } function releaseCamera() { stopQrLoop(); if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } videoEl.srcObject = null; } // ── QR scan — BarcodeDetector (native) with jsQR fallback ──────────── function startQrLoop() { if (typeof BarcodeDetector !== 'undefined') { startBarcodeDetectorLoop(); } else { loadJsQR().then(startJsQrLoop).catch(() => { // jsQR failed to load — just show shutter button immediately if (shutterWrap) shutterWrap.classList.remove('d-none'); }); } } // Native BarcodeDetector path (Chrome/Edge/Android) — more reliable than jsQR function startBarcodeDetectorLoop() { let detector; try { detector = new BarcodeDetector({ formats: ['qr_code'] }); } catch { // BarcodeDetector exists but qr_code format not supported — fall back loadJsQR().then(startJsQrLoop); return; } async function tick() { if (!stream || qrFound) return; if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { try { const codes = await detector.detect(videoEl); if (codes.length > 0 && !qrFound) { qrFound = true; handleQrResult(codes[0].rawValue); return; } } catch { /* video not ready or detect failed — try next frame */ } } rafId = requestAnimationFrame(tick); } rafId = requestAnimationFrame(tick); } // jsQR fallback path (Safari, Firefox, older browsers) function startJsQrLoop() { const ctx = canvasEl.getContext('2d'); function tick() { if (!stream || qrFound) return; if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { canvasEl.width = videoEl.videoWidth; canvasEl.height = videoEl.videoHeight; ctx.drawImage(videoEl, 0, 0); const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height); const code = window.jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); if (code && code.data) { qrFound = true; handleQrResult(code.data); return; } } rafId = requestAnimationFrame(tick); } rafId = requestAnimationFrame(tick); } // ── Camera pre-warm ─────────────────────────────────────────────────── // Check camera permission state without prompting. If already granted, start the stream // silently on page load so Scan Label opens instantly with no browser prompt on this visit. // The idle timer ensures the camera releases if the user never actually scans anything. async function preWarmCamera() { try { if (!navigator.permissions) return; const perm = await navigator.permissions.query({ name: 'camera' }); if (perm.state !== 'granted') return; stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); // Start idle timer — release if user never opens the scanner idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); } catch { /* permission denied or getUserMedia failed — ignore */ } } function loadJsQR() { return new Promise((resolve, reject) => { if (window.jsQR) { resolve(); return; } const s = document.createElement('script'); s.src = JSQR_CDN; s.onload = resolve; s.onerror = () => reject(new Error('Failed to load jsQR')); document.head.appendChild(s); }); } // ── QR result handler ───────────────────────────────────────────────── async function handleQrResult(url) { stopQrLoop(); setScanStatus('info', 'QR code found — looking up product…'); setScanBtnLoading(true); try { const fd = new FormData(); fd.append('qrUrl', url); await submitScan(fd); } finally { setScanBtnLoading(false); } } // ── Vision fallback: grab frame and POST ────────────────────────────── async function captureFrame() { setScanStatus('info', 'Reading label…'); setScanBtnLoading(true); stopQrLoop(); if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) { const ctx = canvasEl.getContext('2d'); // Scale down to ≤1024px wide to keep payload small while preserving label text const maxW = 1024; const scale = videoEl.videoWidth > maxW ? maxW / videoEl.videoWidth : 1; canvasEl.width = Math.round(videoEl.videoWidth * scale); canvasEl.height = Math.round(videoEl.videoHeight * scale); ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); canvasEl.toBlob(async (blob) => { try { const base64 = await blobToBase64(blob); const fd = new FormData(); fd.append('imageBase64', base64); fd.append('mediaType', 'image/jpeg'); await submitScan(fd); } finally { setScanBtnLoading(false); } }, 'image/jpeg', 0.88); } else { // No live camera (e.g. desktop) — fall back to file picker const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = async () => { const file = input.files[0]; if (!file) { setScanBtnLoading(false); return; } try { const base64 = await blobToBase64(file); const fd = new FormData(); fd.append('imageBase64', base64); fd.append('mediaType', file.type || 'image/jpeg'); await submitScan(fd); } finally { setScanBtnLoading(false); } }; input.click(); } } // ── Submit to server and fill form ──────────────────────────────────── async function submitScan(fd) { try { const resp = await fetch(SCAN_URL, { method: 'POST', body: fd }); if (!resp.ok) throw new Error(`Server error ${resp.status}`); const data = await resp.json(); if (!data.success) { setScanStatus('danger', data.errorMessage || 'Scan failed.'); return; } bsModal.hide(); fillFromScan(data); } catch (err) { setScanStatus('danger', 'Scan failed: ' + err.message); } } // ── Fill the inventory form from scan result ─────────────────────────── function fillFromScan(data) { const filled = []; function setIf(id, value, label) { const el = document.getElementById(id); if (el && value != null && String(value).trim()) { el.value = String(value).trim(); filled.push(label); } } function setIfEmpty(id, value, label) { const el = document.getElementById(id); if (el && value != null && String(value).trim() && !el.value.trim()) { el.value = String(value).trim(); filled.push(label); } } setIf('field-manufacturer', data.manufacturer, 'Manufacturer'); setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number'); setIf('field-colorname', data.colorName, 'Color Name'); // Name field: use color name if blank const nameEl = document.getElementById('field-name'); if (nameEl && !nameEl.value.trim() && data.colorName) { nameEl.value = data.colorName; filled.push('Name'); } setIfEmpty('field-description', data.description, 'Description'); setIfEmpty('field-finish', data.finish, 'Finish'); setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp'); setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time'); setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage'); setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency'); if (data.unitPrice > 0) { const costEl = document.getElementById('field-unitcost'); if (costEl && (parseFloat(costEl.value) || 0) === 0) { costEl.value = data.unitPrice; filled.push('Unit Cost'); } } if (data.requiresClearCoat != null) { const cc = document.getElementById('field-clearcoat'); if (cc) { cc.checked = data.requiresClearCoat; filled.push('Clear Coat'); } } if (data.colorFamilies) { const hiddenInput = document.getElementById('field-colorfamilies'); if (hiddenInput && !hiddenInput.value.trim()) { const families = data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean); hiddenInput.value = families.join(','); document.querySelectorAll('.color-family-chip').forEach(chip => { chip.classList.toggle('active', families.includes(chip.dataset.family)); }); filled.push('Color Families'); } } setIf('field-specpageurl', data.productUrl, 'Product URL'); syncLink('field-specpageurl', 'field-specpageurl-link', data.productUrl); setIf('field-sdsurl', data.sdsUrl, 'SDS'); syncLink('field-sdsurl', 'field-sdsurl-link', data.sdsUrl); setIf('field-tdsurl', data.tdsUrl, 'TDS'); syncLink('field-tdsurl', 'field-tdsurl-link', data.tdsUrl); if (data.imageUrl) { const imgInput = document.getElementById('field-imageurl'); const imgEl = document.getElementById('field-imagepreview-img'); const imgWrap = document.getElementById('wrap-imagepreview'); if (imgInput) imgInput.value = data.imageUrl; if (imgEl) imgEl.src = data.imageUrl; if (imgWrap) imgWrap.style.display = ''; filled.push('Image'); } const vendorSel = document.getElementById('field-vendor'); if (vendorSel && !vendorSel.value && data.vendorName) { const needle = data.vendorName.toLowerCase(); const match = Array.from(vendorSel.options).find(o => o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim()) ); if (match) { vendorSel.value = match.value; filled.push('Vendor'); } } const catalogNote = data.wasInCatalog ? ' From catalog' : data.addedToCatalog ? ' Added to platform catalog' : ''; if (filled.length > 0) { showFormStatus('success', `Filled from label scan: ${filled.join(', ')}.${catalogNote}`); } else { showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`); } } // ── Helpers ─────────────────────────────────────────────────────────── function blobToBase64(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result; const comma = result.indexOf(','); resolve(comma >= 0 ? result.slice(comma + 1) : result); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } function syncLink(inputId, linkId, url) { const link = document.getElementById(linkId); if (!link) return; if (url) { link.href = url; link.classList.remove('d-none'); } else { link.classList.add('d-none'); } } function setScanStatus(type, msg) { if (!scanStatusEl) return; scanStatusEl.className = `alert alert-${type} py-2 small mb-0 mt-2`; scanStatusEl.innerHTML = msg; scanStatusEl.classList.remove('d-none'); } function showFormStatus(type, msg) { if (!statusEl) return; statusEl.className = `alert alert-${type} py-2 small mb-3 alert-permanent`; statusEl.innerHTML = msg; statusEl.classList.remove('d-none'); } function setScanBtnLoading(on) { if (!shutterBtn) return; shutterBtn.disabled = on; shutterBtn.innerHTML = on ? 'Reading…' : 'Scan Label Text'; } })();