Files
spotlightcam/frontend/src/pages/EventDetailsPage.jsx
Radosław Gierwiało 71cba01db3 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)
2025-11-14 14:11:24 +01:00

249 lines
8.5 KiB
JavaScript
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.

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>
);
}