feat: implement QR code check-in system for event attendees

- Add UserQRCode component to display unique QR code on user profile
- Add QRCodeScanner component for mobile camera-based scanning
- Create EventCheckInPage for organizers to scan attendee QR codes
- Add backend endpoint POST /api/events/:id/checkin for check-in
- Add check-in button to EventDetailModal for organizers
- Install qrcode.react and html5-qrcode dependencies
- Implement real-time attendee list updates
- Add organizer permission validation
- Prevent duplicate check-ins
- Mobile-optimized with back camera support
This commit is contained in:
2025-11-20 18:52:04 +00:00
parent cbafe85694
commit e793a8e3ea
13 changed files with 870 additions and 235 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { format } from 'date-fns';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -53,6 +54,7 @@ export function EventDetailModal({
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const [showCompletionForm, setShowCompletionForm] = useState(false);
const navigate = useNavigate();
// Check if event date has passed
const hasEventPassed = new Date(event.scheduledDate) < new Date();
@@ -300,23 +302,34 @@ export function EventDetailModal({
{event.status === 'upcoming' && !showCompletionForm && (
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
{canComplete ? (
<div className="flex gap-3">
<button
onClick={() => setShowCompletionForm(!showCompletionForm)}
disabled={isLoading}
className="flex-1 bg-green-600 text-white py-3 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
{showCompletionForm ? 'Hide Completion Form' : 'Complete Event'}
</button>
{onCancel && (
<div className="space-y-3">
<div className="flex gap-3">
<button
onClick={onCancel}
onClick={() => setShowCompletionForm(!showCompletionForm)}
disabled={isLoading}
className="px-6 py-3 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
className="flex-1 bg-green-600 text-white py-3 px-6 rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
Cancel Event
{showCompletionForm ? 'Hide Completion Form' : 'Complete Event'}
</button>
)}
{onCancel && (
<button
onClick={onCancel}
disabled={isLoading}
className="px-6 py-3 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
Cancel Event
</button>
)}
</div>
<button
onClick={() => navigate(`/events/${event.id}/checkin`)}
className="w-full bg-purple-600 text-white py-3 px-6 rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR Code Check-In
</button>
</div>
) : isOrganizer && !hasEventPassed ? (
<div className="space-y-3">
@@ -334,6 +347,15 @@ export function EventDetailModal({
</span>
</div>
</div>
<button
onClick={() => navigate(`/events/${event.id}/checkin`)}
className="w-full bg-purple-600 text-white py-3 px-6 rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
QR Code Check-In
</button>
{onCancel && (
<button
onClick={onCancel}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
interface QRCodeScannerProps {
onScan: (userId: string) => void;
onError?: (error: string) => void;
}
export function QRCodeScanner({ onScan, onError }: QRCodeScannerProps) {
const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const scannerRef = useRef<Html5Qrcode | null>(null);
const scannerIdRef = useRef('qr-reader');
useEffect(() => {
return () => {
// Cleanup on unmount
if (scannerRef.current) {
scannerRef.current.stop().catch(console.error);
}
};
}, []);
const startScanning = async () => {
try {
setError(null);
const scanner = new Html5Qrcode(scannerIdRef.current);
scannerRef.current = scanner;
await scanner.start(
{ facingMode: 'environment' }, // Use back camera
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText) => {
try {
const data = JSON.parse(decodedText);
if (data.type === 'attendee-checkin' && data.userId) {
onScan(data.userId);
} else {
const errorMsg = 'Invalid QR code format';
setError(errorMsg);
onError?.(errorMsg);
}
} catch (err) {
const errorMsg = 'Failed to parse QR code';
setError(errorMsg);
onError?.(errorMsg);
}
},
(errorMessage) => {
// Ignore scanning errors (happens continuously while scanning)
}
);
setIsScanning(true);
} catch (err: any) {
const errorMsg = err.message || 'Failed to start camera';
setError(errorMsg);
onError?.(errorMsg);
setIsScanning(false);
}
};
const stopScanning = async () => {
if (scannerRef.current) {
try {
await scannerRef.current.stop();
scannerRef.current = null;
setIsScanning(false);
} catch (err) {
console.error('Error stopping scanner:', err);
}
}
};
return (
<div className="space-y-4">
<div
id={scannerIdRef.current}
className="w-full rounded-lg overflow-hidden bg-black"
style={{ minHeight: isScanning ? '300px' : '0' }}
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="flex gap-3">
{!isScanning ? (
<button
onClick={startScanning}
className="flex-1 bg-blue-600 text-white px-4 py-3 rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Start Scanning
</button>
) : (
<button
onClick={stopScanning}
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Stop Scanning
</button>
)}
</div>
{isScanning && (
<p className="text-sm text-gray-600 text-center">
Position the QR code within the frame to scan
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { QRCodeSVG } from 'qrcode.react';
import { useUser } from '@clerk/clerk-react';
export function UserQRCode() {
const { user } = useUser();
if (!user) return null;
// Generate QR code data with user ID
const qrData = JSON.stringify({
userId: user.id,
type: 'attendee-checkin',
timestamp: Date.now(),
});
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Your Check-in QR Code
</h3>
<p className="text-sm text-gray-600 mb-4">
Show this QR code to event organizers to check in at events
</p>
<div className="flex justify-center p-4 bg-gray-50 rounded-lg">
<QRCodeSVG
value={qrData}
size={200}
level="H"
includeMargin={true}
/>
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
{user.username || user.firstName || 'User'}
</p>
</div>
);
}

View File

@@ -0,0 +1 @@
export { UserQRCode } from './UserQRCode';