Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main

Reviewed-on: #10
This commit is contained in:
2026-02-14 19:04:42 +00:00

View File

@@ -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<HTMLDivElement>(null);
const scannerRef = useRef<any>(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 (
<div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden">
<div ref={containerRef} className="w-full h-full [&_video]:!object-cover [&_video]:!h-full" />
{isActive && (
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{ready && (
<button
onClick={switchCamera}
className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform"
@@ -173,7 +226,7 @@ function QRScanner({
<VideoCameraIcon className="w-5 h-5" />
</button>
)}
{!isActive && (
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
@@ -189,15 +242,27 @@ function QRScanner({
function ValidTicketScreen({
validation,
onConfirmCheckin,
onClose,
checkingIn,
}: {
validation: TicketValidationResult;
onConfirmCheckin: () => void;
onClose: () => void;
checkingIn: boolean;
}) {
return (
<div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200">
<div className="flex-1 flex flex-col items-center justify-center px-6 text-white">
{/* Close button (dismiss without check-in) */}
<div className="flex justify-end p-4">
<button
onClick={onClose}
className="p-2 rounded-full bg-white/20 text-white active:scale-95 transition-transform"
aria-label="Close"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-6 -mt-14 text-white">
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
<CheckCircleIcon className="w-16 h-16 text-white" />
</div>
@@ -581,7 +646,7 @@ export default function AdminScannerPage() {
const [activeTab, setActiveTab] = useState<ActiveTab>('scan');
// Scanner state
const [cameraActive, setCameraActive] = useState(false);
const [scannerKey, setScannerKey] = useState(0); // increment to force remount
const [scanResult, setScanResult] = useState<ScanResultData>({ 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 (
<div className="min-h-screen bg-gray-950 flex flex-col h-screen max-h-screen overflow-hidden">
<div className="bg-gray-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* ── Sticky Header ── */}
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top">
<div className="flex items-center justify-between gap-3">
@@ -864,14 +915,17 @@ export default function AdminScannerPage() {
{/* ── Tab Content ── */}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* SCAN TAB */}
{activeTab === 'scan' && (
{/* SCAN TAB — scanner fully unmounts when not active */}
{scannerActive && (
<QRScanner
isActive={cameraActive && scanResult.state === 'idle'}
key={scannerKey}
onScan={handleScan}
onActiveChange={setCameraActive}
onError={() => {}}
/>
)}
{activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && (
<div className="flex-1 bg-black" />
)}
{/* SEARCH TAB */}
{activeTab === 'search' && (
@@ -889,6 +943,7 @@ export default function AdminScannerPage() {
<ValidTicketScreen
validation={scanResult.validation}
onConfirmCheckin={handleCheckin}
onClose={resetScan}
checkingIn={checkingIn}
/>
)}