Files
blackcanyontickets/reactrebuild0825/src/features/scanner/useScanner.ts
dzinesco aa81eb5adb feat: add advanced analytics and territory management system
- Add comprehensive analytics components with export functionality
- Implement territory management with manager performance tracking
- Add seatmap components for venue layout management
- Create customer management features with modal interface
- Add advanced hooks for dashboard flags and territory data
- Implement seat selection and venue management utilities
- Add type definitions for ticketing and seatmap systems

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 09:25:10 -06:00

726 lines
22 KiB
TypeScript

/**
* Camera Scanner Hook with BarcodeDetector and ZXing fallback
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserCodeReader, type IScannerControls } from '@zxing/browser';
import { doc, onSnapshot } from 'firebase/firestore';
import { db } from '../../lib/firebase';
import {
captureScannerError,
addScannerBreadcrumb,
setScannerContext,
captureScannerPerformance
} from '../../lib/sentry';
import { QRDebounceManager } from './DebounceManager';
import { ScannerRateLimiter, DeviceAbuseTracker } from './RateLimiter';
import type { ScannerState, AbusePreventionConfig } from './types';
interface ScannerHookOptions {
onScan: (qr: string) => void;
enabled: boolean;
torchEnabled?: boolean;
soundEnabled?: boolean;
vibrationEnabled?: boolean;
eventId?: string;
organizationId?: string;
abusePreventionConfig?: Partial<AbusePreventionConfig>;
}
export function useScanner({
onScan,
enabled,
torchEnabled = false,
soundEnabled = true,
vibrationEnabled = true,
eventId,
organizationId,
abusePreventionConfig = {},
}: ScannerHookOptions) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scannerRef = useRef<IScannerControls | null>(null);
const lastScanRef = useRef<{ qr: string; time: number } | null>(null);
const codeReaderRef = useRef<BrowserCodeReader | null>(null);
const sessionIdRef = useRef<string>('');
const deviceIdRef = useRef<string>('');
const rateLimiterRef = useRef<ScannerRateLimiter | null>(null);
const abuseTrackerRef = useRef<DeviceAbuseTracker | null>(null);
const debounceManagerRef = useRef<QRDebounceManager | null>(null);
const [state, setState] = useState<ScannerState>({
isScanning: false,
cameraPermission: 'prompt',
torchSupported: false,
torchEnabled: false,
eventLoading: false,
scanningEnabled: true, // Default to enabled until we load event data
rateLimitStatus: 'ok',
});
// Fetch event data and listen for scanning control changes
useEffect(() => {
if (!eventId) {
return;
}
setState(prev => ({ ...prev, eventLoading: true }));
// Set up real-time listener for event document
const eventDocRef = doc(db, 'events', eventId);
const unsubscribe = onSnapshot(
eventDocRef,
(docSnapshot) => {
if (docSnapshot.exists()) {
const eventData = docSnapshot.data();
const scanningEnabled = eventData.scanningEnabled !== false; // Default to true if not set
setState(prev => ({
...prev,
eventLoading: false,
scanningEnabled,
eventData: {
id: docSnapshot.id,
title: eventData.title || 'Event',
scanningEnabled,
},
}));
addScannerBreadcrumb('Event data loaded', {
eventId,
scanningEnabled,
eventTitle: eventData.title,
});
} else {
// Event doesn't exist, disable scanning
setState(prev => ({
...prev,
eventLoading: false,
scanningEnabled: false,
eventData: undefined,
}));
addScannerBreadcrumb('Event not found', {
eventId,
});
}
},
(error) => {
captureScannerError(error, {
operation: 'event_data_fetch',
sessionId: sessionIdRef.current,
eventId,
additionalData: {
errorCode: error.code,
errorMessage: error.message,
},
});
setState(prev => ({
...prev,
eventLoading: false,
scanningEnabled: false,
}));
}
);
return () => {
unsubscribe();
};
}, [eventId]);
// Initialize scanner session tracking and abuse prevention
useEffect(() => {
// Get or create session ID
let sessionId = sessionStorage.getItem('scanner_session_id');
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
sessionStorage.setItem('scanner_session_id', sessionId);
}
sessionIdRef.current = sessionId;
// Get or create device ID
let deviceId = localStorage.getItem('scanner_device_id');
if (!deviceId) {
deviceId = `device_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
localStorage.setItem('scanner_device_id', deviceId);
}
deviceIdRef.current = deviceId;
// Initialize abuse prevention systems
const config = {
rateLimitEnabled: true,
maxScansPerSecond: 8,
debounceTimeMs: 2000,
deviceTrackingEnabled: true,
ticketStatusCheckEnabled: true,
...abusePreventionConfig,
};
if (config.rateLimitEnabled) {
rateLimiterRef.current = new ScannerRateLimiter({
maxScansPerSecond: config.maxScansPerSecond,
});
}
if (config.deviceTrackingEnabled) {
abuseTrackerRef.current = new DeviceAbuseTracker();
}
debounceManagerRef.current = new QRDebounceManager({
debounceTimeMs: config.debounceTimeMs,
});
// Set Sentry context
setScannerContext({
sessionId,
deviceId,
eventId,
organizationId,
});
addScannerBreadcrumb('Scanner initialized with abuse prevention', {
sessionId,
deviceId: deviceId.split('_')[1]?.substring(0, 8),
eventId,
abusePreventionEnabled: {
rateLimitEnabled: config.rateLimitEnabled,
deviceTrackingEnabled: config.deviceTrackingEnabled,
maxScansPerSecond: config.maxScansPerSecond,
},
});
}, [eventId, organizationId, abusePreventionConfig]);
// Initialize scanner
const initializeScanner = useCallback(async () => {
if (!enabled || !videoRef.current || state.isScanning) {return;}
const startTime = performance.now();
addScannerBreadcrumb('Camera initialization started');
try {
setState(prev => ({ ...prev, cameraPermission: 'loading' }));
// Request camera permission
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
streamRef.current = stream;
videoRef.current.srcObject = stream;
// Check torch support
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
const torchSupported = 'torch' in capabilities;
setState(prev => ({
...prev,
isScanning: true,
cameraPermission: 'granted',
torchSupported,
}));
const initDuration = performance.now() - startTime;
captureScannerPerformance('camera_initialization', initDuration, {
torchSupported,
sessionId: sessionIdRef.current,
});
addScannerBreadcrumb('Camera initialized successfully', {
duration: initDuration,
torchSupported,
});
// Start scanning
await startScanning();
} catch (error) {
const initDuration = performance.now() - startTime;
captureScannerError(error as Error, {
operation: 'camera_initialization',
sessionId: sessionIdRef.current,
deviceId: deviceIdRef.current,
eventId,
additionalData: {
duration: initDuration,
enabled,
hasVideoElement: !!videoRef.current,
},
});
console.error('Failed to initialize camera:', error);
setState(prev => ({
...prev,
cameraPermission: 'denied',
isScanning: false,
}));
}
}, [enabled, state.isScanning, eventId]);
// Start scanning with BarcodeDetector or ZXing fallback
const startScanning = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) {return;}
// Try native BarcodeDetector first
if ('BarcodeDetector' in window && (window as any).BarcodeDetector) {
try {
const barcodeDetector = new (window as any).BarcodeDetector({
formats: ['qr_code', 'code_128', 'code_39'],
});
const scanLoop = async () => {
if (!state.isScanning || !videoRef.current || !canvasRef.current) {return;}
try {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) {return;}
// Draw video frame to canvas
canvas.width = videoRef.current.videoWidth;
canvas.height = videoRef.current.videoHeight;
context.drawImage(videoRef.current, 0, 0);
// Detect barcodes
const barcodes = await barcodeDetector.detect(canvas);
if (barcodes.length > 0) {
const qr = barcodes[0].rawValue;
handleScanResult(qr);
}
} catch (error) {
captureScannerError(error as Error, {
operation: 'barcode_detection',
sessionId: sessionIdRef.current,
deviceId: deviceIdRef.current,
eventId,
additionalData: {
method: 'BarcodeDetector',
hasCanvas: !!canvasRef.current,
hasVideo: !!videoRef.current,
},
});
console.error('Barcode detection error:', error);
}
// Continue scanning
if (state.isScanning) {
requestAnimationFrame(scanLoop);
}
};
requestAnimationFrame(scanLoop);
return;
} catch (error) {
addScannerBreadcrumb('BarcodeDetector failed, falling back to ZXing', {
error: (error as Error).message,
});
console.warn('BarcodeDetector failed, falling back to ZXing:', error);
}
}
// Fallback to ZXing
try {
const codeReader = new BrowserCodeReader();
codeReaderRef.current = codeReader;
const controls = await codeReader.decodeFromVideoDevice(
undefined, // Auto-select device
videoRef.current,
(result, error) => {
if (result) {
handleScanResult(result.getText());
}
if (error && error.name !== 'NotFoundException') {
captureScannerError(error, {
operation: 'zxing_scan',
sessionId: sessionIdRef.current,
deviceId: deviceIdRef.current,
eventId,
additionalData: {
method: 'ZXing',
errorName: error.name,
},
});
console.error('ZXing scan error:', error);
}
}
);
scannerRef.current = controls;
} catch (error) {
captureScannerError(error as Error, {
operation: 'zxing_initialization',
sessionId: sessionIdRef.current,
deviceId: deviceIdRef.current,
eventId,
additionalData: {
method: 'ZXing',
},
});
console.error('ZXing initialization failed:', error);
}
}, [state.isScanning]);
// Handle scan result with comprehensive abuse prevention
const handleScanResult = useCallback((qr: string) => {
const now = Date.now();
// Check if scanning is enabled for this event
if (!state.scanningEnabled) {
addScannerBreadcrumb('Scan attempt blocked - scanning disabled by admin', {
qr_masked: `${qr.substring(0, 4)}***`,
eventId,
scanningEnabled: state.scanningEnabled,
});
// Harsh vibration for blocked scan
if (vibrationEnabled && 'vibrate' in navigator) {
navigator.vibrate([200, 100, 200, 100, 200]); // Long error pattern
}
return;
}
// Device-level abuse check
if (abuseTrackerRef.current) {
const abuseStatus = abuseTrackerRef.current.checkAbuseStatus(now);
if (abuseStatus.isAbusive) {
setState(prev => ({
...prev,
rateLimitStatus: 'blocked',
deviceBlocked: true,
deviceBlockMessage: abuseStatus.message,
rateLimitCountdown: abuseStatus.backoffTime,
}));
addScannerBreadcrumb('Scan blocked - device abuse detected', {
qr_masked: `${qr.substring(0, 4) }***`,
backoffTime: abuseStatus.backoffTime,
deviceStats: abuseTrackerRef.current.getStats(),
});
// Harsh vibration for blocked scan
if (vibrationEnabled && 'vibrate' in navigator) {
navigator.vibrate([200, 100, 200, 100, 200]); // Long error pattern
}
return;
}
}
// Rate limiting check
if (rateLimiterRef.current) {
const rateCheck = rateLimiterRef.current.checkRate(now);
if (!rateCheck.allowed) {
setState(prev => ({
...prev,
rateLimitStatus: 'blocked',
rateLimitMessage: rateCheck.message,
rateLimitCountdown: rateCheck.waitTime,
}));
// Record violation for device abuse tracking
if (abuseTrackerRef.current) {
abuseTrackerRef.current.recordViolation(now);
}
addScannerBreadcrumb('Scan blocked - rate limit exceeded', {
qr_masked: `${qr.substring(0, 4) }***`,
currentRate: rateCheck.currentRate,
waitTime: rateCheck.waitTime,
rateLimiterStats: rateLimiterRef.current.getStats(now),
});
// Double vibration for rate limit
if (vibrationEnabled && 'vibrate' in navigator) {
navigator.vibrate([100, 100, 100]); // Double vibration pattern
}
return;
}
// Warn if approaching rate limit
if (rateCheck.currentRate > 6) { // Warning at 75% of limit
setState(prev => ({
...prev,
rateLimitStatus: 'warning',
rateLimitMessage: 'Slow down - approaching scan limit',
}));
} else {
setState(prev => ({
...prev,
rateLimitStatus: 'ok',
rateLimitMessage: undefined,
rateLimitCountdown: undefined,
}));
}
}
// Enhanced debouncing check
if (debounceManagerRef.current) {
const debounceCheck = debounceManagerRef.current.checkScan(qr, deviceIdRef.current, now);
if (!debounceCheck.allowed) {
setState(prev => ({
...prev,
debounceActive: true,
debounceCountdown: debounceCheck.remainingTime,
}));
addScannerBreadcrumb('QR scan debounced (enhanced duplicate check)', {
qr_masked: `${qr.substring(0, 4) }***`,
remainingTime: debounceCheck.remainingTime,
lastScanTime: debounceCheck.lastScanTime,
});
// Triple vibration for debounced scan
if (vibrationEnabled && 'vibrate' in navigator) {
navigator.vibrate([50, 50, 50, 50, 50]); // Quick triple pattern
}
return;
}
setState(prev => ({
...prev,
debounceActive: false,
debounceCountdown: undefined,
}));
}
// All checks passed - process the scan
lastScanRef.current = { qr, time: now };
// Record successful scan in abuse prevention systems
if (rateLimiterRef.current) {
rateLimiterRef.current.recordScan(now);
}
if (debounceManagerRef.current) {
debounceManagerRef.current.recordScan(qr, deviceIdRef.current, now);
}
setState(prev => ({
...prev,
lastScannedQR: qr,
lastScanTime: now,
rateLimitStatus: 'ok',
deviceBlocked: false,
}));
addScannerBreadcrumb('QR code successfully scanned with abuse checks', {
qr_masked: `${qr.substring(0, 4) }***`,
sessionId: sessionIdRef.current,
timestamp: new Date(now).toISOString(),
rateLimitStats: rateLimiterRef.current?.getStats(now),
debounceStats: debounceManagerRef.current?.getStats(now),
});
// Success haptic feedback
if (vibrationEnabled && 'vibrate' in navigator) {
navigator.vibrate(100); // Short vibration for success
}
// Audio feedback
if (soundEnabled) {
playSuccessSound();
}
onScan(qr);
}, [onScan, soundEnabled, vibrationEnabled, state.scanningEnabled, eventId]);
// Play success sound
const playSuccessSound = useCallback(() => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // 800 Hz beep
gainNode.gain.value = 0.1;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.1); // 100ms beep
} catch (error) {
// Don't send audio errors to Sentry as they're not critical
addScannerBreadcrumb('Audio feedback failed', {
error: (error as Error).message,
});
console.warn('Audio feedback failed:', error);
}
}, []);
// Toggle torch
const toggleTorch = useCallback(async () => {
if (!state.torchSupported || !streamRef.current) {return;}
try {
const track = streamRef.current.getVideoTracks()[0];
const newTorchState = !state.torchEnabled;
await track.applyConstraints({
advanced: [{ torch: newTorchState }] as any,
});
setState(prev => ({ ...prev, torchEnabled: newTorchState }));
} catch (error) {
captureScannerError(error as Error, {
operation: 'torch_toggle',
sessionId: sessionIdRef.current,
deviceId: deviceIdRef.current,
eventId,
additionalData: {
torchSupported: state.torchSupported,
currentTorchState: state.torchEnabled,
hasStream: !!streamRef.current,
},
});
console.error('Failed to toggle torch:', error);
}
}, [state.torchSupported, state.torchEnabled]);
// Stop scanning
const stopScanning = useCallback(() => {
if (scannerRef.current) {
scannerRef.current.stop();
scannerRef.current = null;
}
if (codeReaderRef.current) {
codeReaderRef.current.reset();
codeReaderRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setState(prev => ({
...prev,
isScanning: false,
torchEnabled: false,
}));
}, []);
// Auto-enable torch in dark environments
useEffect(() => {
if (!torchEnabled || !state.torchSupported || state.torchEnabled) {return;}
// Simple ambient light detection using video luminance
const checkAmbientLight = () => {
if (!videoRef.current || !canvasRef.current) {return;}
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) {return;}
canvas.width = videoRef.current.videoWidth;
canvas.height = videoRef.current.videoHeight;
context.drawImage(videoRef.current, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const {data} = imageData;
let brightness = 0;
for (let i = 0; i < data.length; i += 4) {
brightness += (data[i] + data[i + 1] + data[i + 2]) / 3;
}
brightness = brightness / (data.length / 4);
// If very dark (brightness < 30), auto-enable torch
if (brightness < 30 && !state.torchEnabled) {
toggleTorch();
}
};
const interval = setInterval(checkAmbientLight, 5000); // Check every 5 seconds
return () => clearInterval(interval);
}, [torchEnabled, state.torchSupported, state.torchEnabled, toggleTorch]);
// Initialize when enabled
useEffect(() => {
if (enabled && !state.isScanning) {
initializeScanner();
} else if (!enabled && state.isScanning) {
stopScanning();
}
}, [enabled, initializeScanner, stopScanning, state.isScanning]);
// Update abuse prevention countdowns
useEffect(() => {
let countdownInterval: NodeJS.Timeout | null = null;
const hasActiveCountdown = state.rateLimitCountdown || state.debounceCountdown;
if (hasActiveCountdown) {
countdownInterval = setInterval(() => {
const now = Date.now();
setState(prev => {
const updates: Partial<ScannerState> = {};
// Update rate limit countdown
if (prev.rateLimitCountdown && prev.rateLimitCountdown > 0) {
const newCountdown = Math.max(0, prev.rateLimitCountdown - 100);
updates.rateLimitCountdown = newCountdown;
if (newCountdown === 0) {
updates.rateLimitStatus = 'ok';
updates.rateLimitMessage = undefined;
updates.deviceBlocked = false;
updates.deviceBlockMessage = undefined;
}
}
// Update debounce countdown
if (prev.debounceCountdown && prev.debounceCountdown > 0) {
const newCountdown = Math.max(0, prev.debounceCountdown - 100);
updates.debounceCountdown = newCountdown;
if (newCountdown === 0) {
updates.debounceActive = false;
}
}
return { ...prev, ...updates };
});
}, 100); // Update every 100ms for smooth countdown
}
return () => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
};
}, [state.rateLimitCountdown, state.debounceCountdown]);
// Cleanup on unmount
useEffect(() => () => {
stopScanning();
}, [stopScanning]);
return {
videoRef,
canvasRef,
state,
toggleTorch,
stopScanning,
restart: initializeScanner,
};
}