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>
This commit is contained in:
726
reactrebuild0825/src/features/scanner/useScanner.ts
Normal file
726
reactrebuild0825/src/features/scanner/useScanner.ts
Normal file
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user