diff --git a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs index 26875e4..5cb9397 100644 --- a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs +++ b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs @@ -933,7 +933,9 @@ Rules: /// 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 unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null; var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null; diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 7af264c..be8426c 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -848,7 +848,7 @@ public class InventoryController : Controller requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat, coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb, transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency, - unitPrice = catalogMatch?.UnitPrice ?? 0m, + unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m, imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl, productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl, sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl, diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js index a57e3c3..dd9da27 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-label-scan.js @@ -1,25 +1,24 @@ /** * In-browser powder label scanner for the Inventory Create/Edit forms. * - * QR scanning priority: - * 1. Native BarcodeDetector API (Chrome/Edge/Android) — uses OS-level decoding, - * all supported formats, canvas-snapshot approach for reliability. - * 2. jsQR fallback for Safari/Firefox. + * QR scanning strategy (parallel for maximum compatibility): + * 1. BarcodeDetector (Chrome/Edge/Android) starts immediately — canvas snapshot approach. + * 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously. + * 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: - * - A localStorage flag ('scannerCameraGranted') is set the first time getUserMedia - * succeeds. On subsequent page loads the flag triggers a proactive getUserMedia call - * that succeeds silently when Chrome has the site at "Allow", bypassing any quirk - * where navigator.permissions.query still returns 'prompt' for localhost. - * - 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. + * Camera permission: + * Pre-warm only fires when navigator.permissions.query returns 'granted' so we never + * show a browser prompt on page load — if Chrome has the site at "Ask", the prompt + * appears only when the user explicitly clicks Scan Label (once per page session after + * that, because the stream stays alive between modal opens). */ (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 STORAGE_KEY = 'scannerCameraGranted'; + const JSQR_DELAY_MS = 1500; // start jsQR fallback this long after BarcodeDetector const IDLE_RELEASE_MS = 2 * 60 * 1000; const scanBtn = document.getElementById('scan-label-btn'); @@ -27,11 +26,13 @@ if (!scanBtn) return; - let stream = null; - let rafId = null; - let qrFound = false; - let shutterTimer = null; - let idleTimer = null; + let stream = 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 shutterTimer = null; + let idleTimer = null; // ── Modal elements ──────────────────────────────────────────────────── @@ -52,7 +53,7 @@ if (shutterBtn) shutterBtn.addEventListener('click', captureFrame); 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(); // ── Open / close ────────────────────────────────────────────────────── @@ -95,8 +96,10 @@ } function stopQrLoop() { - if (rafId) { cancelAnimationFrame(rafId); rafId = null; } - if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; } + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = null; } + if (jsqrTimer) { clearTimeout(jsqrTimer); jsqrTimer = null; } + if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; } } function releaseCamera() { @@ -107,51 +110,54 @@ } async function startStream() { - const s = await navigator.mediaDevices.getUserMedia({ + return navigator.mediaDevices.getUserMedia({ 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() { - 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 { + if (!navigator.permissions) return; + const perm = await navigator.permissions.query({ name: 'camera' }); + if (perm.state !== 'granted') return; stream = await startStream(); idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS); - } catch { - localStorage.removeItem(STORAGE_KEY); - } + } catch { /* permission denied or getUserMedia unavailable — ignore */ } } - // ── QR scanning: BarcodeDetector (canvas) → jsQR fallback ──────────── + // ── QR scanning: BarcodeDetector + jsQR in parallel ────────────────── function startQrLoop() { - if (typeof BarcodeDetector !== 'undefined') { + const hasBD = typeof BarcodeDetector !== 'undefined'; + + if (hasBD) { 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() { let detector; 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(); detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] }); } catch { - loadJsQR().then(startJsQrLoop); - return; + return; // BarcodeDetector unavailable — jsQR timer will handle it } const ctx = canvasEl.getContext('2d'); @@ -160,7 +166,6 @@ if (!stream || qrFound) return; if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) { try { - // Snapshot to canvas — more reliable than detecting from the live video element canvasEl.width = videoEl.videoWidth; canvasEl.height = videoEl.videoHeight; ctx.drawImage(videoEl, 0, 0); @@ -170,7 +175,7 @@ handleQrResult(codes[0].rawValue); return; } - } catch { /* frame not ready or detect failed — try next frame */ } + } catch { /* frame not ready — try next */ } } rafId = requestAnimationFrame(tick); } @@ -178,29 +183,31 @@ rafId = requestAnimationFrame(tick); } + // jsQR loop — separate canvas context to avoid interfering with BarcodeDetector function startJsQrLoop() { - const ctx = canvasEl.getContext('2d'); + const canvas2 = document.createElement('canvas'); + const ctx2 = canvas2.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); + canvas2.width = videoEl.videoWidth; + canvas2.height = videoEl.videoHeight; + ctx2.drawImage(videoEl, 0, 0); + const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height); const code = window.jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); - if (code && code.data) { + if (code && code.data && !qrFound) { qrFound = true; handleQrResult(code.data); return; } } - rafId = requestAnimationFrame(tick); + rafId2 = requestAnimationFrame(tick); } - rafId = requestAnimationFrame(tick); + rafId2 = requestAnimationFrame(tick); } function loadJsQR() {