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

@@ -24,6 +24,7 @@ const ReportDetailDemo = lazy(() => import('./pages/ReportDetailDemo'));
const WhyCleanPage = lazy(() => import('./pages/WhyCleanPage'));
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
const EnvironmentalDataDemo = lazy(() => import('./pages/EnvironmentalDataDemo'));
const EventCheckInPage = lazy(() => import('./pages/EventCheckInPage'));
// Services
import { apiClient } from './services/api';
@@ -152,6 +153,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/events/:eventId/checkin"
element={
<ProtectedRoute>
<Layout>
<EventCheckInPage />
</Layout>
</ProtectedRoute>
}
/>
{/* Demo routes for testing */}
<Route

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';

View File

@@ -0,0 +1,274 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useUser } from '@clerk/clerk-react';
import { QRCodeScanner } from '@/components/event/QRCodeScanner';
import { apiClient } from '@/services/api';
import { CleanupEvent, EventAttendee } from '@/types';
export function EventCheckInPage() {
const { eventId } = useParams<{ eventId: string }>();
const { user } = useUser();
const navigate = useNavigate();
const [event, setEvent] = useState<CleanupEvent | null>(null);
const [attendees, setAttendees] = useState<EventAttendee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [checkedInUsers, setCheckedInUsers] = useState<Set<string>>(new Set());
useEffect(() => {
loadEventData();
}, [eventId]);
const loadEventData = async () => {
if (!eventId) return;
try {
setLoading(true);
setError(null);
// Load event details
const eventResponse = await apiClient.get<CleanupEvent>(`/events/${eventId}`);
setEvent(eventResponse);
// Check if user is the organizer
if (eventResponse.organizerClerkId !== user?.id) {
setError('Only the event organizer can access check-in');
return;
}
// Load attendees
const attendeesResponse = await apiClient.get<EventAttendee[]>(
`/events/${eventId}/attendees`
);
setAttendees(attendeesResponse);
// Track already checked-in users
const checkedIn = new Set(
attendeesResponse.filter((a) => a.attended).map((a) => a.userId)
);
setCheckedInUsers(checkedIn);
} catch (err: any) {
console.error('Error loading event data:', err);
setError(err.message || 'Failed to load event data');
} finally {
setLoading(false);
}
};
const handleScan = async (clerkUserId: string) => {
if (!eventId) return;
try {
// Find the attendee by Clerk ID
const attendee = attendees.find((a) => {
// We need to match by Clerk ID, but we have user.id (database ID)
// We'll need to check the user object
return false; // This needs backend support
});
// For now, we'll use a different approach - send the Clerk ID to backend
// and let it handle the lookup
const response = await apiClient.post(
`/events/${eventId}/checkin`,
{ clerkUserId }
);
setSuccessMessage(`Successfully checked in ${response.username || 'user'}`);
setCheckedInUsers((prev) => new Set([...prev, clerkUserId]));
// Reload attendees to get updated status
setTimeout(() => {
loadEventData();
setSuccessMessage(null);
}, 2000);
} catch (err: any) {
console.error('Error checking in user:', err);
setError(err.message || 'Failed to check in user');
setTimeout(() => setError(null), 3000);
}
};
const handleScanError = (errorMsg: string) => {
setError(errorMsg);
setTimeout(() => setError(null), 3000);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading event...</p>
</div>
</div>
);
}
if (error && !event) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-md p-6 max-w-md w-full">
<div className="text-red-600 mb-4">
<svg
className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 text-center mb-2">
Access Denied
</h2>
<p className="text-gray-600 text-center mb-4">{error}</p>
<Link
to="/events"
className="block w-full bg-blue-600 text-white text-center px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Events
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
to={`/events/${eventId}`}
className="text-blue-600 hover:text-blue-700 mb-4 inline-flex items-center"
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Event
</Link>
<h1 className="text-3xl font-bold text-gray-900">Event Check-In</h1>
{event && (
<p className="text-gray-600 mt-2">{event.title}</p>
)}
</div>
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">
<div className="flex items-center">
<svg
className="w-5 h-5 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
{successMessage}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* QR Scanner */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Scan Attendee QR Code
</h2>
<QRCodeScanner onScan={handleScan} onError={handleScanError} />
</div>
{/* Attendee List */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Registered Attendees ({attendees.length})
</h2>
{attendees.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No attendees registered yet
</p>
) : (
<div className="space-y-3">
{attendees.map((attendee) => (
<div
key={attendee.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
attendee.attended
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center gap-3">
{attendee.user.avatarUrl ? (
<img
src={attendee.user.avatarUrl}
alt={attendee.user.username}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{attendee.user.username.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="font-medium text-gray-900">
{attendee.user.firstName && attendee.user.lastName
? `${attendee.user.firstName} ${attendee.user.lastName}`
: attendee.user.username}
</p>
<p className="text-sm text-gray-500">
@{attendee.user.username}
</p>
</div>
</div>
{attendee.attended && (
<div className="flex items-center text-green-600">
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="ml-1 text-sm font-medium">Checked In</span>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { userService, type UserProfile, type UserActivity } from '@/services/use
import type { Report, CleanupEvent } from '@/types';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { NotificationSettings, type NotificationPreferences, BadgeDisplay } from '@/components/auth';
import { UserQRCode } from '@/components/user/UserQRCode';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
@@ -420,6 +421,11 @@ export function UserProfilePage() {
<div className="p-6">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* QR Code for own profile */}
{isOwnProfile && (
<UserQRCode />
)}
<div>
<h2 className="text-xl font-semibold mb-4">Contribution Map</h2>
{reports.length > 0 && reports[0] ? (