feat: add QR code event check-in system

Backend:
- Add event_checkin_tokens table with unique tokens per event
- Implement GET /api/events/:slug/details endpoint (on-demand token generation)
- Implement POST /api/events/checkin/:token endpoint (date validation only in production)
- Implement DELETE /api/events/:slug/leave endpoint
- Add comprehensive test suite for check-in endpoints

Frontend:
- Add EventDetailsPage with QR code display, participant list, and stats
- Add EventCheckinPage with success/error screens
- Add "Leave Event" button with confirmation modal to EventChatPage
- Install qrcode.react library for QR code generation
- Update routing and API client with new endpoints

Features:
- QR codes valid from (startDate-1d) to (endDate+1d)
- Development mode bypasses date validation for testing
- Automatic participant count tracking
- Duplicate check-in prevention
- Token reuse for same event (generated once, cached)
This commit is contained in:
Radosław Gierwiało
2025-11-14 14:11:24 +01:00
parent 5bea2ad133
commit 71cba01db3
11 changed files with 1095 additions and 1 deletions

View File

@@ -0,0 +1,248 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, Check, Users, Calendar, MapPin, QrCode } from 'lucide-react';
import Layout from '../components/layout/Layout';
import { eventsAPI } from '../services/api';
export default function EventDetailsPage() {
const { slug } = useParams();
const [eventDetails, setEventDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
useEffect(() => {
fetchEventDetails();
}, [slug]);
const fetchEventDetails = async () => {
try {
setLoading(true);
const response = await eventsAPI.getDetails(slug);
setEventDetails(response.data);
} catch (err) {
console.error('Error loading event details:', err);
setError(err.message || 'Failed to load event details');
} finally {
setLoading(false);
}
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(eventDetails.checkin.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading event details...</p>
</div>
</div>
</Layout>
);
}
if (error) {
return (
<Layout>
<div className="max-w-4xl mx-auto p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800">{error}</p>
<Link to="/events" className="text-red-600 hover:underline mt-2 inline-block">
Back to Events
</Link>
</div>
</div>
</Layout>
);
}
if (!eventDetails) {
return null;
}
const { event, checkin, participants, stats } = eventDetails;
return (
<Layout>
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-6">
<Link to="/events" className="text-primary-600 hover:underline mb-2 inline-block">
&larr; Back to Events
</Link>
<h1 className="text-3xl font-bold text-gray-900">{event.name}</h1>
<div className="flex items-center gap-4 mt-2 text-gray-600">
<div className="flex items-center gap-1">
<MapPin size={16} />
<span>{event.location}</span>
</div>
<div className="flex items-center gap-1">
<Calendar size={16} />
<span>{formatDate(event.startDate)} - {formatDate(event.endDate)}</span>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* QR Code Section */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<QrCode className="text-primary-600" />
Event Check-in QR Code
</h2>
{/* QR Code Display */}
<div className="bg-white p-6 rounded-lg border-2 border-gray-200 mb-4 flex justify-center">
<QRCodeSVG
value={checkin.url}
size={256}
level="H"
includeMargin={true}
/>
</div>
{/* Check-in URL */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Check-in Link
</label>
<div className="flex gap-2">
<input
type="text"
value={checkin.url}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors flex items-center gap-2"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{/* Valid Dates */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm">
<p className="font-medium text-blue-900 mb-1">Valid Period</p>
<p className="text-blue-700">
{formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)}
</p>
{process.env.NODE_ENV === 'development' && (
<p className="text-blue-600 mt-2 text-xs">
Development mode: Date validation disabled
</p>
)}
</div>
</div>
{/* Participants Section */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<Users className="text-primary-600" />
Participants ({stats.totalParticipants})
</h2>
{participants.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Users size={48} className="mx-auto mb-2 text-gray-300" />
<p>No participants yet</p>
<p className="text-sm">Share the QR code to get started!</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{participants.map((participant) => (
<div
key={participant.userId}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-semibold">
{participant.avatar ? (
<img
src={participant.avatar}
alt={participant.username}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<span>{participant.username.charAt(0).toUpperCase()}</span>
)}
</div>
{/* User Info */}
<div className="flex-1">
<p className="font-medium text-gray-900">
{participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: participant.username}
</p>
<p className="text-sm text-gray-600">@{participant.username}</p>
</div>
{/* Joined Date */}
<div className="text-xs text-gray-500">
{new Date(participant.joinedAt).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex gap-4">
<Link
to={`/events/${slug}/chat`}
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition-colors text-center font-medium"
>
Go to Event Chat
</Link>
<button
onClick={() => window.print()}
className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
Print QR Code
</button>
</div>
{/* Print Styles */}
<style>{`
@media print {
body * {
visibility: hidden;
}
.print-area, .print-area * {
visibility: visible;
}
.print-area {
position: absolute;
left: 0;
top: 0;
}
}
`}</style>
</div>
</Layout>
);
}