97cf6dcbf0
Uses Permissions API (non-prompting) to check camera state on load. If state === 'granted', silently starts the stream so Scan Label opens instantly with no browser prompt on subsequent page visits. Falls back gracefully when Permissions API is unavailable or permission is 'prompt'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
/**
|
|
* 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', '<span class="spinner-border spinner-border-sm me-1"></span>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', '<span class="spinner-border spinner-border-sm me-1"></span>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', '<span class="spinner-border spinner-border-sm me-1"></span>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
|
|
? ' <span class="badge bg-secondary ms-1">From catalog</span>'
|
|
: data.addedToCatalog
|
|
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
|
|
: '';
|
|
|
|
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
|
|
? '<span class="spinner-border spinner-border-sm me-1"></span>Reading…'
|
|
: '<i class="bi bi-camera me-1"></i>Scan Label Text';
|
|
}
|
|
})();
|