From a11da5a977a1c395eeefd3f05d3bdc29aed4f329 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 14 Feb 2026 19:03:29 +0000 Subject: [PATCH] Scanner: close button on valid ticket, camera lifecycle fix - Add X close button on valid ticket screen to dismiss without check-in - Rewrite QRScanner: full unmount when leaving Scan tab, stop MediaStream tracks - Remount scanner via key when tab active; no hidden DOM - Use 100dvh for mobile height; force layout reflow after camera start - visibilitychange handler for tab suspend/resume Co-authored-by: Cursor --- frontend/src/app/admin/scanner/page.tsx | 195 +++++++++++++++--------- 1 file changed, 125 insertions(+), 70 deletions(-) diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index a8b1a07..47f20e4 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -9,6 +9,7 @@ import { QrCodeIcon, CheckCircleIcon, XCircleIcon, + XMarkIcon, MagnifyingGlassIcon, ArrowPathIcon, ClockIcon, @@ -76,86 +77,135 @@ function vibrate(pattern: number | number[]) { } catch {} } +// ─── Stop all tracks on a media stream ─────────────────────── +function stopAllTracks() { + try { + // Find all video elements and stop their streams + document.querySelectorAll('video').forEach((video) => { + const stream = video.srcObject as MediaStream | null; + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + video.srcObject = null; + } + }); + } catch {} +} + // ─── QR Scanner Component ──────────────────────────────────── +// This component fully mounts/unmounts — use a key prop externally +// to force a fresh instance when the scan tab becomes active. function QRScanner({ onScan, - isActive, - onActiveChange, + onError, }: { onScan: (code: string) => void; - isActive: boolean; - onActiveChange: (active: boolean) => void; + onError: () => void; }) { const containerRef = useRef(null); const scannerRef = useRef(null); - const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`); + const mountedRef = useRef(true); + const elementId = useRef(`qr-scanner-${Date.now()}`); const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment'); + const [ready, setReady] = useState(false); - useEffect(() => { - if (containerRef.current && !document.getElementById(scannerElementId.current)) { - const scannerDiv = document.createElement('div'); - scannerDiv.id = scannerElementId.current; - scannerDiv.style.width = '100%'; - containerRef.current.appendChild(scannerDiv); + // Full cleanup helper + const destroyScanner = useCallback(async () => { + if (scannerRef.current) { + try { await scannerRef.current.stop(); } catch {} + try { scannerRef.current.clear(); } catch {} + scannerRef.current = null; } - return () => { - if (scannerRef.current) { - try { scannerRef.current.stop().catch(() => {}); } catch {} - scannerRef.current = null; - } - }; + stopAllTracks(); }, []); + // Start scanner on mount, destroy on unmount useEffect(() => { + mountedRef.current = true; let cancelled = false; - const startScanner = async () => { - const elementId = scannerElementId.current; - const element = document.getElementById(elementId); - if (!element) return; + const init = async () => { + const container = containerRef.current; + if (!container) return; + + // Create a fresh div for the scanner + const id = elementId.current; + container.innerHTML = ''; + const div = document.createElement('div'); + div.id = id; + div.style.width = '100%'; + div.style.height = '100%'; + container.appendChild(div); try { const { Html5Qrcode } = await import('html5-qrcode'); if (cancelled) return; - if (scannerRef.current) { - try { await scannerRef.current.stop(); } catch {} - scannerRef.current = null; - } - if (cancelled) return; - const scanner = new Html5Qrcode(elementId); + const scanner = new Html5Qrcode(id); scannerRef.current = scanner; await scanner.start( { facingMode }, { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 }, - (decodedText: string) => onScan(decodedText), + (decodedText: string) => { + if (mountedRef.current) onScan(decodedText); + }, () => {} ); + + if (cancelled) { + await destroyScanner(); + return; + } + + // Force layout recalculation after camera starts + requestAnimationFrame(() => { + if (container) { + container.style.display = 'none'; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + container.offsetHeight; // force reflow + container.style.display = ''; + } + if (mountedRef.current) setReady(true); + }); } catch (error: any) { console.error('Scanner error:', error); - if (!cancelled) { + if (!cancelled && mountedRef.current) { toast.error('Failed to start camera. Check permissions.'); - onActiveChange(false); + onError(); } } }; - const stopScanner = async () => { - if (scannerRef.current) { - try { await scannerRef.current.stop(); } catch {} - scannerRef.current = null; + init(); + + return () => { + cancelled = true; + mountedRef.current = false; + destroyScanner(); + }; + }, [facingMode]); // restart when camera flips + + // Handle browser visibility change (suspend/resume) + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === 'hidden') { + destroyScanner(); + } else if (document.visibilityState === 'visible' && mountedRef.current) { + // Re-trigger by flipping facingMode back-and-forth (forces useEffect re-run) + setFacingMode((prev) => { + // Toggle and toggle back to trigger the effect + const temp = prev === 'environment' ? 'user' : 'environment'; + setTimeout(() => { + if (mountedRef.current) setFacingMode(prev); + }, 100); + return temp; + }); } }; - if (isActive) { - startScanner(); - } else { - stopScanner(); - } - - return () => { cancelled = true; }; - }, [isActive, facingMode, onScan, onActiveChange]); + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, [destroyScanner]); const switchCamera = () => { setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment')); @@ -163,8 +213,11 @@ function QRScanner({ return (
-
- {isActive && ( +
+ {ready && ( )} - {!isActive && ( + {!ready && (
@@ -189,15 +242,27 @@ function QRScanner({ function ValidTicketScreen({ validation, onConfirmCheckin, + onClose, checkingIn, }: { validation: TicketValidationResult; onConfirmCheckin: () => void; + onClose: () => void; checkingIn: boolean; }) { return (
-
+ {/* Close button (dismiss without check-in) */} +
+ +
+
@@ -581,7 +646,7 @@ export default function AdminScannerPage() { const [activeTab, setActiveTab] = useState('scan'); // Scanner state - const [cameraActive, setCameraActive] = useState(false); + const [scannerKey, setScannerKey] = useState(0); // increment to force remount const [scanResult, setScanResult] = useState({ state: 'idle' }); const [lastScannedCode, setLastScannedCode] = useState(''); const [checkingIn, setCheckingIn] = useState(false); @@ -638,21 +703,8 @@ export default function AdminScannerPage() { loadStats(); }, [selectedEventId]); - // Auto-start camera on page load (Scan tab) - useEffect(() => { - if (!loading && activeTab === 'scan') { - setCameraActive(true); - } - }, [loading, activeTab]); - - // Pause camera when switching away from scan tab - useEffect(() => { - if (activeTab !== 'scan') { - setCameraActive(false); - } else if (scanResult.state === 'idle') { - setCameraActive(true); - } - }, [activeTab, scanResult.state]); + // When scan tab becomes active again or scan result is dismissed, bump key to remount scanner + const scannerActive = activeTab === 'scan' && scanResult.state === 'idle' && !loading; // Validate ticket const validateTicket = useCallback(async (code: string) => { @@ -690,7 +742,6 @@ export default function AdminScannerPage() { if (decodedText === lastScannedCodeRef.current) return; lastScannedCodeRef.current = decodedText; setLastScannedCode(decodedText); - setCameraActive(false); let code = decodedText; const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/); @@ -768,12 +819,12 @@ export default function AdminScannerPage() { } }; - // Reset scan state + // Reset scan state — bump key so scanner fully remounts const resetScan = () => { setScanResult({ state: 'idle' }); setLastScannedCode(''); lastScannedCodeRef.current = ''; - if (activeTab === 'scan') setCameraActive(true); + setScannerKey((k) => k + 1); }; // Get selected event name @@ -788,7 +839,7 @@ export default function AdminScannerPage() { } return ( -
+
{/* ── Sticky Header ── */}
@@ -864,14 +915,17 @@ export default function AdminScannerPage() { {/* ── Tab Content ── */}
- {/* SCAN TAB */} - {activeTab === 'scan' && ( + {/* SCAN TAB — scanner fully unmounts when not active */} + {scannerActive && ( {}} /> )} + {activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && ( +
+ )} {/* SEARCH TAB */} {activeTab === 'search' && ( @@ -889,6 +943,7 @@ export default function AdminScannerPage() { )} -- 2.49.1