import React, { useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Html5Qrcode } from 'html5-qrcode'; import { FaCamera, FaTimes } from 'react-icons/fa'; import { parseQrPayloadToAppPath } from '../utils/qrNavigation'; export interface QRScannerProps { isOpen: boolean; onClose: () => void; /** Override default navigation; receive original QR text + parsed in-app path */ onScan?: (decodedText: string, appPath: string | null) => void; title?: string; /** Prefer rear camera on phones */ facingMode?: 'environment' | 'user'; } /** * Camera QR scanner modal. On success, navigates inside the SPA (including mobile webview) * using React Router so users stay in the app instead of opening an external browser. */ const QRScanner: React.FC = ({ isOpen, onClose, onScan, title = 'Scan QR Code', facingMode = 'environment', }) => { const navigate = useNavigate(); const reactId = useId(); const readerId = `qr-reader-${reactId.replace(/:/g, '')}`; const scannerRef = useRef(null); const handledRef = useRef(false); const [error, setError] = useState(null); const [starting, setStarting] = useState(false); const stopScanner = async () => { const scanner = scannerRef.current; scannerRef.current = null; if (!scanner) return; try { if (scanner.isScanning) { await scanner.stop(); } scanner.clear(); } catch { // ignore stop/clear errors when camera already released } }; const handleDecoded = async (decodedText: string) => { if (handledRef.current) return; handledRef.current = true; const appPath = parseQrPayloadToAppPath(decodedText); if (onScan) { await stopScanner(); onClose(); onScan(decodedText, appPath); return; } if (appPath) { await stopScanner(); onClose(); // In-app navigation — works in mobile webview / PWA without leaving the app navigate(appPath); return; } handledRef.current = false; setError(`Unrecognized QR code: ${decodedText}`); }; useEffect(() => { if (!isOpen) return; let cancelled = false; handledRef.current = false; setError(null); setStarting(true); const start = async () => { try { await stopScanner(); if (cancelled) return; const scanner = new Html5Qrcode(readerId); scannerRef.current = scanner; await scanner.start( { facingMode }, { fps: 10, qrbox: (viewfinderWidth, viewfinderHeight) => { const edge = Math.min(viewfinderWidth, viewfinderHeight); const size = Math.max(180, Math.floor(edge * 0.7)); return { width: size, height: size }; }, aspectRatio: 1, }, (decodedText) => { void handleDecoded(decodedText); }, () => { // ignore frame-level "not found" callbacks } ); } catch (err) { console.error('QR scanner start failed:', err); if (!cancelled) { const message = err instanceof Error ? err.message : 'Could not open camera'; setError( message.includes('NotAllowedError') || message.toLowerCase().includes('permission') ? 'Camera permission denied. Please allow camera access and try again.' : `Camera unavailable: ${message}` ); } } finally { if (!cancelled) setStarting(false); } }; // Small delay so the reader DOM node is mounted const timer = window.setTimeout(() => { void start(); }, 100); return () => { cancelled = true; window.clearTimeout(timer); void stopScanner(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, readerId, facingMode]); if (!isOpen) return null; return createPortal(

{title}

Point the camera at an asset QR code. You will stay inside the app.

{starting && !error && (

Starting camera…

)} {error && (
{error}
)}
, document.body ); }; export interface ScanQRButtonProps { className?: string; label?: string; title?: string; onScan?: (decodedText: string, appPath: string | null) => void; } /** * Reusable Scan QR trigger + scanner modal. Use anywhere you need camera QR → in-app navigation. */ export const ScanQRButton: React.FC = ({ className, label = 'Scan QR', title, onScan, }) => { const [isOpen, setIsOpen] = useState(false); return ( <> setIsOpen(false)} onScan={onScan} title={title} /> ); }; export default QRScanner;