Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx
2026-07-01 13:31:31 +03:00

598 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { QrCode, Camera, RotateCcw, CheckCircle, XCircle, User, MapPin, Clock, Train, CameraOff } from 'lucide-react';
import { ticketsApi, apiClient } from '@/lib/api';
import { useAuthStore } from '@/lib/auth-store';
import { formatDateTime } from '@/lib/utils';
import { useRouter } from 'next/navigation';
import Header from '@/components/layout/Header';
// Add QR Scanner component
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
const startCamera = async () => {
try {
setIsInitializing(true);
setCameraError(null);
// Check if mediaDevices is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
setCameraError(errorMsg);
onError(errorMsg);
setIsInitializing(false);
return;
}
// First, stop any existing stream
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
// Request camera access with simpler fallback
let mediaStream: MediaStream | null = null;
try {
// Try with environment (back) camera first
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
});
} catch {
// Fallback to any available camera with simple constraints
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
} catch (fallbackErr) {
throw fallbackErr;
}
}
if (!mediaStream) {
throw new Error('Failed to get media stream');
}
if (!videoRef.current) {
throw new Error('Video element not found');
}
const video = videoRef.current;
video.srcObject = mediaStream;
// Wait for video to be ready with proper event handling
await new Promise<void>((resolve, reject) => {
let resolved = false;
const cleanup = () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('loadeddata', onLoadedData);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onVideoError);
};
const finishResolve = () => {
if (!resolved) {
resolved = true;
cleanup();
resolve();
}
};
const onLoadedMetadata = () => finishResolve();
const onLoadedData = () => finishResolve();
const onCanPlay = () => finishResolve();
const onVideoError = (_e: Event) => {
cleanup();
reject(new Error('Video failed to load'));
};
// Add multiple event listeners for better compatibility
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('loadeddata', onLoadedData);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onVideoError);
setTimeout(() => finishResolve(), 2000);
});
try {
await video.play();
} catch {
await new Promise(resolve => setTimeout(resolve, 100));
try { await video.play(); } catch { /* continue */ }
}
// Set state to show video
setStream(mediaStream);
setIsScanning(true);
setIsInitializing(false);
} catch (error: any) {
let errorMsg = 'Camera access failed. Please check permissions and try again.';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMsg = 'Camera permission denied. Please allow camera access in your browser settings and try again.';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMsg = 'No camera found. Please connect a camera and try again.';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMsg = 'Camera is already in use by another application. Please close other apps using the camera.';
} else if (error.name === 'OverconstrainedError') {
errorMsg = 'Camera does not meet the requirements. Please try a different camera.';
} else if (error.name === 'SecurityError') {
errorMsg = 'Camera access blocked due to security settings. Please use HTTPS or check your browser security settings.';
}
setCameraError(errorMsg);
onError(errorMsg);
// Clean up on error
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsScanning(false);
setIsInitializing(false);
}
};
const stopCamera = useCallback(() => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsScanning(false);
setCameraError(null);
}, [stream]);
// QR code scanning with jsqr
const scanFrame = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isScanning) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
try {
const jsQR = (window as any).jsQR;
if (jsQR) {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
});
if (code) {
onScan(code.data);
stopCamera();
}
}
} catch { /* ignore scan errors */ }
}
}, [isScanning, onScan, stopCamera]);
useEffect(() => {
if (isScanning) {
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
}
return () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
}
stopCamera();
};
}, [isScanning, scanFrame, stopCamera]);
// Load jsqr from CDN
useEffect(() => {
if (!(window as any).jsQR) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}
}, []);
return (
<div className="space-y-4">
{/* Video viewer - always rendered, visibility controlled by display style */}
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full min-h-[320px] object-cover block"
style={{ display: 'block', visibility: 'visible' }}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg animate-pulse"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4 pointer-events-none">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<p className="text-white/70 text-center text-xs mt-1">Scanning...</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<CameraOff className="w-5 h-5" />
Stop Camera
</button>
</div>
{/* Start button and loading state */}
{!isScanning && !isInitializing && (
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-4 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<Camera className="w-5 h-5" />
Scan QR Code
</button>
{cameraError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<p className="text-red-700 dark:text-red-300 text-sm">{cameraError}</p>
</div>
)}
</div>
)}
{/* Loading state */}
{isInitializing && (
<div className="space-y-3">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="text-blue-700 dark:text-blue-300 font-medium">Starting camera...</p>
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
Please allow camera access when prompted by your browser
</p>
</div>
</div>
<button
onClick={() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
}}
className="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-6 rounded-xl transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
}
export default function BoardingPage() {
const [qrInput, setQrInput] = useState('');
const [lastScanned, setLastScanned] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { user, isAuthenticated } = useAuthStore();
const router = useRouter();
// Check authentication on mount
useEffect(() => {
if (!isAuthenticated) {
router.push('/login');
return;
}
}, [isAuthenticated, router]);
// Get current agent data
const { data: agentData } = useQuery({
queryKey: ['agent-me'],
queryFn: () => apiClient.get<any>('/agents/me'),
enabled: !!user,
retry: false,
});
const boardingMutation = useMutation({
mutationFn: (qrCodeOrRef: string) =>
ticketsApi.scanAndBoard(qrCodeOrRef, {
validatorId: agentData?.id || user?.id || 'BACKOFFICE',
gateId: 'MOBILE-GATE',
}),
onSuccess: (result) => {
setError(null);
if (result.success) {
setSuccess('Passenger boarded successfully!');
setLastScanned(result.boarding);
setQrInput('');
// Auto-focus for next scan
setTimeout(() => inputRef.current?.focus(), 1000);
} else {
setError(result.error || 'Boarding failed');
setLastScanned(null);
}
},
onError: (error: any) => {
setError(error?.response?.data?.message || error.message || 'Boarding failed');
setSuccess(null);
setLastScanned(null);
},
});
const handleScan = (inputValue?: string) => {
const valueToScan = inputValue || qrInput.trim();
if (!valueToScan) {
setError('Please enter QR code or booking reference');
return;
}
setError(null);
setSuccess(null);
boardingMutation.mutate(valueToScan);
};
const handleButtonClick = () => {
handleScan();
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleButtonClick();
}
};
const clearAll = () => {
setQrInput('');
setError(null);
setSuccess(null);
setLastScanned(null);
inputRef.current?.focus();
};
useEffect(() => {
// Auto-focus on mount for mobile scanning (only if authenticated)
if (isAuthenticated) {
inputRef.current?.focus();
}
}, [isAuthenticated]);
// Show loading or redirect if not authenticated
if (!isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Redirecting to login...</p>
</div>
</div>
);
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950">
<div className="min-h-full bg-gradient-to-br from-emerald-50 to-blue-50 dark:from-slate-900 dark:to-slate-800 p-4">
{/* Mobile-optimized container */}
<div className="max-w-md mx-auto space-y-6">
{/* Header */}
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-emerald-600 rounded-full mb-4">
<QrCode className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Boarding</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Scan ticket QR codes to board passengers</p>
{agentData && (
<div className="text-sm text-emerald-600 dark:text-emerald-400 mt-2">
Agent: {agentData.agentCode || agentData.name}
</div>
)}
</div>
{/* Scanner Input */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
<div className="space-y-4">
{/* Camera Scanner */}
<QRScanner
onScan={(data) => {
setQrInput(data);
handleScan(data);
}}
onError={setError}
/>
{/* Manual Input */}
<div className="text-center text-gray-500 dark:text-gray-400 text-sm">OR</div>
<div className="relative">
<input
ref={inputRef}
type="text"
value={qrInput}
onChange={(e) => setQrInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type ticket number"
className="w-full px-4 py-4 text-lg border border-gray-300 dark:border-slate-600 rounded-xl
focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500
dark:bg-slate-700 dark:text-white dark:placeholder-slate-400
font-mono tracking-wide"
autoCapitalize="characters"
autoComplete="off"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={handleButtonClick}
disabled={boardingMutation.isPending || !qrInput.trim()}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
text-white font-semibold py-4 px-6 rounded-xl transition-colors
disabled:cursor-not-allowed text-lg"
>
{boardingMutation.isPending ? 'Boarding...' : 'Board Passenger'}
</button>
<button
onClick={clearAll}
className="bg-gray-500 hover:bg-gray-600 text-white font-semibold py-4 px-6 rounded-xl transition-colors"
>
<RotateCcw className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Success Message */}
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
<span className="text-green-800 dark:text-green-200 font-semibold text-lg">{success}</span>
</div>
{lastScanned && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300 font-medium">
{lastScanned.passengerName}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
{lastScanned.route}
</span>
</div>
<div className="flex items-center gap-2">
<Train className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
{lastScanned.trainName} - Coach {lastScanned.coach}, Seat {lastScanned.seat}
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
Boarded: {formatDateTime(lastScanned.boardedAt)} ({lastScanned.leg})
</span>
</div>
{lastScanned.isRoundTrip && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mt-3">
<p className="text-blue-700 dark:text-blue-300 text-sm">
Round-trip ticket: Scan again for return journey
</p>
</div>
)}
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
</div>
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
📧 Email & SMS notifications sent to passenger
</div>
</div>
)}
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-6">
<div className="flex items-center gap-3">
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
<span className="text-red-800 dark:text-red-200 font-semibold">{error}</span>
</div>
</div>
)}
{/* Instructions */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-2xl p-6">
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>
<li> Red error shows validation issues</li>
</ul>
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-700">
<p className="text-blue-700 dark:text-blue-300 text-xs">
<strong>Tip:</strong> If camera doesn't open, check browser permissions in Settings Privacy Camera
</p>
</div>
</div>
{/* Quick Stats */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
<h3 className="text-gray-900 dark:text-white font-semibold mb-3">Session Summary</h3>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
Ready to scan
</span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
);
}