Fix QR detection (parallel loops), price extraction, and camera pre-warm
QR scanning: - Run BarcodeDetector and jsQR in parallel — jsQR starts after JSQR_DELAY_MS (1.5 s) so both decode simultaneously. BarcodeDetector silently returns empty arrays for some QR variants; running jsQR in parallel via a separate rAF loop (rafId2) and its own off-screen canvas catches those cases. First decoder to find anything calls handleQrResult and sets qrFound = true; the other stops. Price extraction (two bugs): - ScanLabel: unitPrice was catalogMatch?.UnitPrice ?? 0m, ignoring aiResult .UnitCostPerLb entirely when no catalog match — changed to fall through to AI result - AppendOffer: only read JSON-LD "price" field; Shopify AggregateOffer uses "lowPrice" instead — now checked as fallback so Prismatic Powders prices are found Camera pre-warm: - Reverted localStorage approach (caused getUserMedia to fire on every page load, showing Chrome's "Ask" prompt immediately before user clicked anything) - Restored Permissions API gate: preWarmCamera only calls getUserMedia when navigator.permissions.query returns 'granted', never risks a page-load prompt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -933,7 +933,9 @@ Rules:
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void AppendOffer(JsonElement offer, StringBuilder sb)
|
private static void AppendOffer(JsonElement offer, StringBuilder sb)
|
||||||
{
|
{
|
||||||
var price = offer.TryGetProperty("price", out var p) ? p.ToString() : null;
|
// Accept "price" (Offer) or "lowPrice" (AggregateOffer — used by Shopify and others)
|
||||||
|
var price = offer.TryGetProperty("price", out var p) ? p.ToString() :
|
||||||
|
offer.TryGetProperty("lowPrice", out var lp) ? lp.ToString() : null;
|
||||||
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
|
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
|
||||||
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
|
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
|
||||||
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
|
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
|
||||||
|
|||||||
@@ -848,7 +848,7 @@ public class InventoryController : Controller
|
|||||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||||
unitPrice = catalogMatch?.UnitPrice ?? 0m,
|
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
|
||||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||||
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* In-browser powder label scanner for the Inventory Create/Edit forms.
|
* In-browser powder label scanner for the Inventory Create/Edit forms.
|
||||||
*
|
*
|
||||||
* QR scanning priority:
|
* QR scanning strategy (parallel for maximum compatibility):
|
||||||
* 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding,
|
* 1. BarcodeDetector (Chrome/Edge/Android) starts immediately — canvas snapshot approach.
|
||||||
* all supported formats, canvas-snapshot approach for reliability.
|
* 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously.
|
||||||
* 2. jsQR fallback for Safari/Firefox.
|
* First one to decode anything wins. Running both covers cases where BarcodeDetector
|
||||||
|
* silently returns empty arrays for certain QR variants (e.g. Prismatic Powders).
|
||||||
*
|
*
|
||||||
* Camera permission strategy:
|
* Camera permission:
|
||||||
* - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia
|
* Pre-warm only fires when navigator.permissions.query returns 'granted' so we never
|
||||||
* succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call
|
* show a browser prompt on page load — if Chrome has the site at "Ask", the prompt
|
||||||
* that succeeds silently when Chrome has the site at "Allow", bypassing any quirk
|
* appears only when the user explicitly clicks Scan Label (once per page session after
|
||||||
* where navigator.permissions.query still returns 'prompt' for localhost.
|
* that, because the stream stays alive between modal opens).
|
||||||
* - The MediaStream is kept alive between modal opens (only closed after 2 min idle
|
|
||||||
* or page unload) so re-opening the scanner within a session never re-prompts.
|
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const SCAN_URL = '/Inventory/ScanLabel';
|
const SCAN_URL = '/Inventory/ScanLabel';
|
||||||
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
|
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
|
||||||
const STORAGE_KEY = 'scannerCameraGranted';
|
const JSQR_DELAY_MS = 1500; // start jsQR fallback this long after BarcodeDetector
|
||||||
const IDLE_RELEASE_MS = 2 * 60 * 1000;
|
const IDLE_RELEASE_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const scanBtn = document.getElementById('scan-label-btn');
|
const scanBtn = document.getElementById('scan-label-btn');
|
||||||
@@ -28,7 +27,9 @@
|
|||||||
if (!scanBtn) return;
|
if (!scanBtn) return;
|
||||||
|
|
||||||
let stream = null;
|
let stream = null;
|
||||||
let rafId = null;
|
let rafId = null; // BarcodeDetector rAF
|
||||||
|
let rafId2 = null; // jsQR rAF (parallel fallback)
|
||||||
|
let jsqrTimer = null; // timer that starts jsQR loop
|
||||||
let qrFound = false;
|
let qrFound = false;
|
||||||
let shutterTimer = null;
|
let shutterTimer = null;
|
||||||
let idleTimer = null;
|
let idleTimer = null;
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
||||||
window.addEventListener('beforeunload', releaseCamera);
|
window.addEventListener('beforeunload', releaseCamera);
|
||||||
|
|
||||||
// Pre-warm if we've ever gotten camera permission on this site
|
// Pre-warm camera if browser has already granted permission (no prompt risk)
|
||||||
preWarmCamera();
|
preWarmCamera();
|
||||||
|
|
||||||
// ── Open / close ──────────────────────────────────────────────────────
|
// ── Open / close ──────────────────────────────────────────────────────
|
||||||
@@ -96,7 +97,9 @@
|
|||||||
|
|
||||||
function stopQrLoop() {
|
function stopQrLoop() {
|
||||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||||
if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; }
|
if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = null; }
|
||||||
|
if (jsqrTimer) { clearTimeout(jsqrTimer); jsqrTimer = null; }
|
||||||
|
if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseCamera() {
|
function releaseCamera() {
|
||||||
@@ -107,51 +110,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startStream() {
|
async function startStream() {
|
||||||
const s = await navigator.mediaDevices.getUserMedia({
|
return navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||||
});
|
});
|
||||||
localStorage.setItem(STORAGE_KEY, '1'); // remember that user has granted before
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Camera pre-warm ───────────────────────────────────────────────────
|
// ── Camera pre-warm (no-prompt-on-page-load guarantee) ────────────────
|
||||||
|
|
||||||
// On page load, if we've seen a grant before, call getUserMedia now.
|
|
||||||
// When Chrome has the site at "Allow" this succeeds silently — no prompt.
|
|
||||||
// If Chrome reverted to "Ask" the user sees the prompt on load (once) rather
|
|
||||||
// than when they tap Scan Label. If permission was revoked, we clear the flag.
|
|
||||||
async function preWarmCamera() {
|
async function preWarmCamera() {
|
||||||
if (!localStorage.getItem(STORAGE_KEY)) return;
|
// Only attempt if the Permissions API confirms the user has already granted access.
|
||||||
|
// Skipping when state is 'prompt' ensures we never show a browser dialog on page load.
|
||||||
try {
|
try {
|
||||||
|
if (!navigator.permissions) return;
|
||||||
|
const perm = await navigator.permissions.query({ name: 'camera' });
|
||||||
|
if (perm.state !== 'granted') return;
|
||||||
stream = await startStream();
|
stream = await startStream();
|
||||||
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
|
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
|
||||||
} catch {
|
} catch { /* permission denied or getUserMedia unavailable — ignore */ }
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── QR scanning: BarcodeDetector (canvas) → jsQR fallback ────────────
|
// ── QR scanning: BarcodeDetector + jsQR in parallel ──────────────────
|
||||||
|
|
||||||
function startQrLoop() {
|
function startQrLoop() {
|
||||||
if (typeof BarcodeDetector !== 'undefined') {
|
const hasBD = typeof BarcodeDetector !== 'undefined';
|
||||||
|
|
||||||
|
if (hasBD) {
|
||||||
startBarcodeDetectorLoop();
|
startBarcodeDetectorLoop();
|
||||||
} else {
|
|
||||||
loadJsQR().then(startJsQrLoop).catch(() => {
|
|
||||||
if (shutterWrap) shutterWrap.classList.remove('d-none');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always start jsQR after a short delay — runs in parallel with BarcodeDetector
|
||||||
|
// (or immediately if BarcodeDetector isn't available). This ensures Prismatic-style
|
||||||
|
// QR codes that BarcodeDetector silently misses still get decoded by jsQR.
|
||||||
|
jsqrTimer = setTimeout(() => {
|
||||||
|
if (!qrFound) {
|
||||||
|
loadJsQR().then(() => { if (!qrFound) startJsQrLoop(); }).catch(() => {
|
||||||
|
if (!hasBD && shutterWrap) shutterWrap.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, hasBD ? JSQR_DELAY_MS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarcodeDetector loop — canvas snapshot for reliability
|
||||||
async function startBarcodeDetectorLoop() {
|
async function startBarcodeDetectorLoop() {
|
||||||
let detector;
|
let detector;
|
||||||
try {
|
try {
|
||||||
// Use every format the browser supports — we don't hardcode qr_code only
|
|
||||||
// in case the label uses data_matrix or another variant.
|
|
||||||
const supported = await BarcodeDetector.getSupportedFormats();
|
const supported = await BarcodeDetector.getSupportedFormats();
|
||||||
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
|
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
|
||||||
} catch {
|
} catch {
|
||||||
loadJsQR().then(startJsQrLoop);
|
return; // BarcodeDetector unavailable — jsQR timer will handle it
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = canvasEl.getContext('2d');
|
const ctx = canvasEl.getContext('2d');
|
||||||
@@ -160,7 +166,6 @@
|
|||||||
if (!stream || qrFound) return;
|
if (!stream || qrFound) return;
|
||||||
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
|
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
|
||||||
try {
|
try {
|
||||||
// Snapshot to canvas — more reliable than detecting from the live video element
|
|
||||||
canvasEl.width = videoEl.videoWidth;
|
canvasEl.width = videoEl.videoWidth;
|
||||||
canvasEl.height = videoEl.videoHeight;
|
canvasEl.height = videoEl.videoHeight;
|
||||||
ctx.drawImage(videoEl, 0, 0);
|
ctx.drawImage(videoEl, 0, 0);
|
||||||
@@ -170,7 +175,7 @@
|
|||||||
handleQrResult(codes[0].rawValue);
|
handleQrResult(codes[0].rawValue);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* frame not ready or detect failed — try next frame */ }
|
} catch { /* frame not ready — try next */ }
|
||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
@@ -178,29 +183,31 @@
|
|||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsQR loop — separate canvas context to avoid interfering with BarcodeDetector
|
||||||
function startJsQrLoop() {
|
function startJsQrLoop() {
|
||||||
const ctx = canvasEl.getContext('2d');
|
const canvas2 = document.createElement('canvas');
|
||||||
|
const ctx2 = canvas2.getContext('2d');
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
if (!stream || qrFound) return;
|
if (!stream || qrFound) return;
|
||||||
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
|
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
|
||||||
canvasEl.width = videoEl.videoWidth;
|
canvas2.width = videoEl.videoWidth;
|
||||||
canvasEl.height = videoEl.videoHeight;
|
canvas2.height = videoEl.videoHeight;
|
||||||
ctx.drawImage(videoEl, 0, 0);
|
ctx2.drawImage(videoEl, 0, 0);
|
||||||
const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height);
|
const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
|
||||||
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
|
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
|
||||||
inversionAttempts: 'attemptBoth'
|
inversionAttempts: 'attemptBoth'
|
||||||
});
|
});
|
||||||
if (code && code.data) {
|
if (code && code.data && !qrFound) {
|
||||||
qrFound = true;
|
qrFound = true;
|
||||||
handleQrResult(code.data);
|
handleQrResult(code.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId2 = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId2 = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadJsQR() {
|
function loadJsQR() {
|
||||||
|
|||||||
Reference in New Issue
Block a user