mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
598 lines
23 KiB
TypeScript
598 lines
23 KiB
TypeScript
'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>
|
||
);
|
||
} |