let stream = null; let scannerActive = false; let scanInterval = null; let scanState = 'scanning'; // scanning | too_close | too_far | poor_light | success | failed let scanAnimFrame = null; // Guidance text map for each state const GUIDANCE_TEXTS = { scanning: { overlay: 'Focus the QR code', title: '', desc: 'Slowly move closer \nor farther until the QR is sharp' }, too_close: { overlay: 'Move back slowly', title: '', desc: 'Move back a little more' }, too_far: { overlay: 'Move closer', title: '', desc: 'Move a bit closer to the QR code' }, poor_light: { overlay: 'Improve light', title: '', desc: 'Improve the illumination' }, searching: { overlay: 'Searching...', title: '', desc: '' }, success: { overlay: '', title: '\u2705', desc: '' }, failed: { overlay: '', title: '\u274C', desc: 'Verification failed\nPlease scan the same QR code' } }; /** * Set the dynamic guidance text shown inside the camera viewfinder (green on dark band) * @param {string} text - The text to display, or empty string to hide */ function setOverlayText(text) { const el = document.getElementById('dynamicText'); if (!el) return; if (!text || !text.trim()) { el.style.display = 'none'; el.textContent = ''; } else { el.style.display = 'block'; el.textContent = text; } } /** * Set the info area text below the progress bar (icon + title + description) * @param {string} title - Title text (can be an emoji/icon) * @param {string} desc - Description text */ function setInfoText(title, desc) { const titleEl = document.getElementById('infoTextTitle'); const descEl = document.getElementById('infoTextDescription'); if (titleEl) titleEl.textContent = title || ''; if (descEl) descEl.textContent = desc || ''; } /** * Set the overall scanning state — updates overlay text, info text, button label, and colors * @param {string} state - One of the keys in GUIDANCE_TEXTS */ function setScanState(state) { scanState = state; const texts = GUIDANCE_TEXTS[state] || GUIDANCE_TEXTS.scanning; setOverlayText(texts.overlay); setInfoText(texts.title, texts.desc); // Update action button based on state const btn = document.getElementById('scanButton'); const btnText = document.getElementById('scanButtonText'); if (!btn || !btnText) return; // Clear inline style so CSS rules take full control btn.style.backgroundImage = ''; btn.disabled = false; switch (state) { case 'scanning': case 'too_close': case 'too_far': case 'poor_light': btnText.textContent = 'Stop'; break; case 'failed': btnText.textContent = 'Retry'; break; default: break; } // Update info icon color for error states const infoIcoContainer = document.getElementById('infoIcoContainer'); if (infoIcoContainer) { if (state === 'failed') { infoIcoContainer.style.color = '#ef4444'; } else { infoIcoContainer.style.color = ''; } } } async function initScanner() { const scannerModal = document.getElementById('scannerModal'); const videoElement = document.getElementById('videoElement'); const canvasElement = document.getElementById('canvasElement'); const canvas = canvasElement.getContext('2d'); try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', advanced: [{ torch: false }] } }).catch(() => { // Fallback: some devices don't accept torch in getUserMedia return navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); }); videoElement.srcObject = stream; scannerModal.classList.add('active'); scannerActive = true; // Switch overlay to blue (scanning state) const overlayImg = document.getElementById('overlayImage'); if (overlayImg) { overlayImg.src = (window.__authnowImages && window.__authnowImages.blue_overlay) || ''; overlayImg.style.display = ''; } // Initialize UI state setScanState('scanning'); console.log('[DEBUG] progressBarContainer rect:', document.getElementById('progressBarContainer')?.getBoundingClientRect()); videoElement.onloadedmetadata = () => { canvasElement.width = videoElement.videoWidth; canvasElement.height = videoElement.videoHeight; startScanning(videoElement, canvasElement, canvas); }; } catch (error) { console.error('Camera access error:', error); if (typeof showToast === 'function') { showToast('Cannot access camera. Please check your permissions.', 'danger'); } } } function startScanning(video, canvas, ctx) { const progressFill = document.getElementById('scanProgressBar'); let progress = 0; let frameCount = 0; // Downsample to max 640px wide to reduce jsQR workload on high-res cameras const MAX_SCAN_WIDTH = 640; scanInterval = setInterval(() => { if (!scannerActive) { clearInterval(scanInterval); return; } if (video.readyState === video.HAVE_ENOUGH_DATA) { // Only resize canvas when video dimensions change (not every tick) const scale = Math.min(1, MAX_SCAN_WIDTH / video.videoWidth); const scanW = Math.round(video.videoWidth * scale); const scanH = Math.round(video.videoHeight * scale); if (canvas.width !== scanW || canvas.height !== scanH) { canvas.width = scanW; canvas.height = scanH; } ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const code = jsQR(imageData.data, imageData.width, imageData.height); frameCount++; // Animate progress bar if (progressFill) { progress = progress >= 95 ? 0 : progress + 5; progressFill.style.width = progress + '%'; // Gradient color shift as progress increases const hue = 120 - (progress / 100) * 40; // green → yellow-ish progressFill.style.background = `linear-gradient(90deg, hsl(${hue}, 80%, 50%), hsl(${hue + 20}, 80%, 55%))`; } if (code) { if (progressFill) { progressFill.style.width = '100%'; progressFill.style.background = 'linear-gradient(90deg, #22c55e, #16a34a)'; } handleScanResult(code.data); } else { // Every ~30 frames (~9s at 300ms interval), cycle through guidance hints if (frameCount % 30 === 0) { const hints = ['scanning', 'too_close', 'too_far']; const idx = Math.floor(frameCount / 30) % hints.length; setScanState(hints[idx]); } } } }, 300); } async function handleScanResult(qrData) { stopScanner(true); let scanSuccess = false; try { let location = null; if (navigator.geolocation) { try { const position = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('timeout')), 5000); navigator.geolocation.getCurrentPosition( (pos) => { clearTimeout(timer); resolve(pos); }, (err) => { clearTimeout(timer); reject(err); }, { timeout: 5000, maximumAge: 60000 } ); }); location = { latitude: position.coords.latitude, longitude: position.coords.longitude, location: `${position.coords.latitude.toFixed(4)},${position.coords.longitude.toFixed(4)}` }; } catch (error) { console.log('Location access denied or timed out'); } } const response = await apiRequest('/verify/scan', { method: 'POST', body: JSON.stringify({ qr_code_data: qrData, ...location }), skipAuth: true }); if (response.success) { localStorage.setItem('verificationResult', JSON.stringify(response.data)); scanSuccess = true; // Show success state inside the scanner viewfinder document.getElementById('scanSuccessPanel')?.classList.add('active'); // Progress bar: jump to 100% immediately via inline style (no transition dependency) const pb = document.getElementById('scanProgressBar'); if (pb) { pb.style.transition = 'none'; pb.style.width = '100%'; pb.style.background = 'linear-gradient(90deg, #22c55e, #16a34a)'; } // Show snowflake overlay const successOverlay = document.getElementById('scanSuccessOverlay'); if (successOverlay) successOverlay.style.display = 'flex'; // Button: disable, no spinner const btn = document.getElementById('scanButton'); if (btn) { btn.disabled = true; btn.style.backgroundImage = ''; } document.getElementById('scanButtonText').textContent = 'Stop'; // Info area: show "Redirecting ..." document.querySelector('.scanner-info-area')?.classList.add('success'); setInfoText('', 'Redirecting ...'); // After 2s, switch to brand loading overlay setTimeout(() => { if (successOverlay) successOverlay.style.display = 'none'; const loadingEl = document.getElementById('loadingOverlay'); const brandLogo = document.getElementById('scanSuccessBrandLogo'); if (brandLogo && response.data.product?.brand) { brandLogo.src = response.data.product.brand; brandLogo.style.display = ''; } if (loadingEl) { loadingEl.style.display = 'flex'; loadingEl.classList.add('active'); } // Step 3: after 1.5s on brand loading, redirect setTimeout(() => { window.location.href = '/result'; }, 1500); }, 2000); return; } else { setScanState('failed'); } } catch (error) { setScanState('failed'); } } function stopScanner(skipOverlay) { scannerActive = false; torchOn = false; if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; } if (scanInterval) { clearInterval(scanInterval); scanInterval = null; } // Reset overlay image back to init state const overlayImg = document.getElementById('overlayImage'); if (overlayImg) { overlayImg.src = (window.__authnowImages && window.__authnowImages.init_overlay) || ''; overlayImg.style.display = ''; } const progressFill = document.getElementById('scanProgressBar'); if (progressFill && !progressFill.classList.contains('success')) { progressFill.style.width = '0%'; } // Clear overlay text setOverlayText(''); } // Torch (flashlight) toggle let torchOn = false; function toggleTorch() { if (!stream) return; const track = stream.getVideoTracks()[0]; if (!track) return; torchOn = !torchOn; applyTorchConstraints(track, torchOn); const btn = document.getElementById('torchBtn'); if (btn) btn.classList.toggle('active', torchOn); } // Apply torch + focusMode together, mirroring app2 applyAdvancedSettings logic async function applyTorchConstraints(videoTrack, torchON) { if (!('getCapabilities' in videoTrack)) return; const capabilities = videoTrack.getCapabilities(); const newConstraints = {}; const focusModes = capabilities.focusMode || []; if (focusModes.includes('continuous')) { newConstraints.focusMode = 'continuous'; } else if (focusModes.includes('single-shot')) { newConstraints.focusMode = 'single-shot'; } // Match app2: only set torch when capabilities.torch is truthy if (capabilities.torch) newConstraints.torch = torchON; if (Object.keys(newConstraints).length > 0) { await videoTrack.applyConstraints({ advanced: [newConstraints] }); } } // Bind close button (new UI uses id="closeScanner") document.addEventListener('DOMContentLoaded', () => { document.getElementById('closeScanner')?.addEventListener('click', stopScanner); document.getElementById('torchBtn')?.addEventListener('click', toggleTorch); }); // Expose setScanState globally for external control (e.g., from index.blade inline scripts) window.setScanState = setScanState; window.setOverlayText = setOverlayText;