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,18 @@
-- CreateTable
CREATE TABLE "event_checkin_tokens" (
"id" SERIAL NOT NULL,
"event_id" INTEGER NOT NULL,
"token" VARCHAR(50) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "event_checkin_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "event_checkin_tokens_event_id_key" ON "event_checkin_tokens"("event_id");
-- CreateIndex
CREATE UNIQUE INDEX "event_checkin_tokens_token_key" ON "event_checkin_tokens"("token");
-- AddForeignKey
ALTER TABLE "event_checkin_tokens" ADD CONSTRAINT "event_checkin_tokens_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -75,10 +75,24 @@ model Event {
chatRooms ChatRoom[] chatRooms ChatRoom[]
matches Match[] matches Match[]
participants EventParticipant[] participants EventParticipant[]
checkinToken EventCheckinToken?
@@map("events") @@map("events")
} }
// Event check-in tokens (QR code tokens for event access)
model EventCheckinToken {
id Int @id @default(autoincrement())
eventId Int @unique @map("event_id")
token String @unique @default(cuid()) @db.VarChar(50)
createdAt DateTime @default(now()) @map("created_at")
// Relations
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@map("event_checkin_tokens")
}
// Chat rooms (event chat and private 1:1 chat) // Chat rooms (event chat and private 1:1 chat)
model ChatRoom { model ChatRoom {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())

View File

@@ -0,0 +1,263 @@
const request = require('supertest');
const app = require('../app');
const { prisma } = require('../utils/db');
const { generateToken } = require('../utils/auth');
let testUser;
let testEvent;
let authToken;
beforeAll(async () => {
// Clean up database
await prisma.eventParticipant.deleteMany({});
await prisma.eventCheckinToken.deleteMany({});
await prisma.chatRoom.deleteMany({});
await prisma.event.deleteMany({});
await prisma.user.deleteMany({});
// Create test user
testUser = await prisma.user.create({
data: {
username: 'checkintest',
email: 'checkin@test.com',
passwordHash: 'hashedpassword',
emailVerified: true,
},
});
// Generate auth token
authToken = generateToken(testUser.id);
// Create test event
testEvent = await prisma.event.create({
data: {
slug: 'test-event-slug',
name: 'Test Event',
location: 'Test Location',
startDate: new Date('2025-06-01'),
endDate: new Date('2025-06-03'),
description: 'Test event description',
participantsCount: 0,
},
});
// Create chat room for event
await prisma.chatRoom.create({
data: {
eventId: testEvent.id,
type: 'event',
},
});
});
afterAll(async () => {
// Clean up
await prisma.eventParticipant.deleteMany({});
await prisma.eventCheckinToken.deleteMany({});
await prisma.chatRoom.deleteMany({});
await prisma.event.deleteMany({});
await prisma.user.deleteMany({});
await prisma.$disconnect();
});
describe('Event Check-in API Tests', () => {
describe('GET /api/events/:slug/details', () => {
it('should get event details with generated check-in token', async () => {
const response = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body.data).toHaveProperty('event');
expect(response.body.data).toHaveProperty('checkin');
expect(response.body.data).toHaveProperty('participants');
expect(response.body.data).toHaveProperty('stats');
// Event data
expect(response.body.data.event).toHaveProperty('slug', testEvent.slug);
expect(response.body.data.event).toHaveProperty('name', testEvent.name);
// Check-in token
expect(response.body.data.checkin).toHaveProperty('token');
expect(response.body.data.checkin).toHaveProperty('url');
expect(response.body.data.checkin).toHaveProperty('validFrom');
expect(response.body.data.checkin).toHaveProperty('validUntil');
expect(response.body.data.checkin.url).toContain(response.body.data.checkin.token);
// Stats
expect(response.body.data.stats).toHaveProperty('totalParticipants', 0);
expect(response.body.data.participants).toEqual([]);
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.get('/api/events/non-existent-slug/details')
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(404);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Event not found');
});
it('should require authentication', async () => {
await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.expect(401);
});
});
describe('POST /api/events/checkin/:token', () => {
let checkinToken;
beforeAll(async () => {
// Get check-in token
const detailsResponse = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
checkinToken = detailsResponse.body.data.checkin.token;
});
it('should check-in user with valid token', async () => {
const response = await request(app)
.post(`/api/events/checkin/${checkinToken}`)
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('alreadyCheckedIn', false);
expect(response.body.data).toHaveProperty('event');
expect(response.body.data).toHaveProperty('joinedAt');
expect(response.body.data.event).toHaveProperty('slug', testEvent.slug);
});
it('should detect already checked-in user', async () => {
const response = await request(app)
.post(`/api/events/checkin/${checkinToken}`)
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('alreadyCheckedIn', true);
});
it('should return 404 for invalid token', async () => {
const response = await request(app)
.post('/api/events/checkin/invalid-token-123')
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(404);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Invalid check-in token');
});
it('should require authentication', async () => {
await request(app)
.post(`/api/events/checkin/${checkinToken}`)
.expect(401);
});
it('should update participants count', async () => {
const detailsResponse = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailsResponse.body.data.stats.totalParticipants).toBeGreaterThan(0);
expect(detailsResponse.body.data.participants.length).toBeGreaterThan(0);
expect(detailsResponse.body.data.participants[0]).toHaveProperty('userId', testUser.id);
expect(detailsResponse.body.data.participants[0]).toHaveProperty('username', testUser.username);
});
});
describe('DELETE /api/events/:slug/leave', () => {
it('should allow user to leave event', async () => {
const response = await request(app)
.delete(`/api/events/${testEvent.slug}/leave`)
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('success', true);
expect(response.body).toHaveProperty('message', 'Successfully left the event');
});
it('should return error if user is not participant', async () => {
const response = await request(app)
.delete(`/api/events/${testEvent.slug}/leave`)
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'You are not a participant of this event');
});
it('should return 404 for non-existent event', async () => {
const response = await request(app)
.delete('/api/events/non-existent-slug/leave')
.set('Authorization', `Bearer ${authToken}`)
.expect('Content-Type', /json/)
.expect(404);
expect(response.body).toHaveProperty('success', false);
expect(response.body).toHaveProperty('error', 'Event not found');
});
it('should require authentication', async () => {
await request(app)
.delete(`/api/events/${testEvent.slug}/leave`)
.expect(401);
});
it('should update participants count after leaving', async () => {
// First check-in
const detailsResponse1 = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
const checkinToken = detailsResponse1.body.data.checkin.token;
await request(app)
.post(`/api/events/checkin/${checkinToken}`)
.set('Authorization', `Bearer ${authToken}`);
// Then leave
await request(app)
.delete(`/api/events/${testEvent.slug}/leave`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Check participants count
const detailsResponse2 = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailsResponse2.body.data.stats.totalParticipants).toBe(0);
expect(detailsResponse2.body.data.participants).toEqual([]);
});
});
describe('Check-in token generation', () => {
it('should reuse existing token for same event', async () => {
const response1 = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
const token1 = response1.body.data.checkin.token;
const response2 = await request(app)
.get(`/api/events/${testEvent.slug}/details`)
.set('Authorization', `Bearer ${authToken}`);
const token2 = response2.body.data.checkin.token;
expect(token1).toBe(token2);
});
});
});

View File

@@ -179,4 +179,269 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => {
} }
}); });
// POST /api/events/checkin/:token - Check-in to event using QR code token
router.post('/checkin/:token', authenticate, async (req, res, next) => {
try {
const { token } = req.params;
const userId = req.user.id;
// Find check-in token
const checkinToken = await prisma.eventCheckinToken.findUnique({
where: { token },
include: {
event: true,
},
});
if (!checkinToken) {
return res.status(404).json({
success: false,
error: 'Invalid check-in token',
});
}
const event = checkinToken.event;
// Validate dates (only in production)
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
const now = new Date();
const validFrom = new Date(event.startDate);
validFrom.setDate(validFrom.getDate() - 1);
const validUntil = new Date(event.endDate);
validUntil.setDate(validUntil.getDate() + 1);
if (now < validFrom || now > validUntil) {
return res.status(400).json({
success: false,
error: 'Check-in is not available for this event at this time',
});
}
}
// Check if user is already participating
const existingParticipant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: {
userId,
eventId: event.id,
},
},
});
if (existingParticipant) {
// User already checked in - return event info
return res.json({
success: true,
alreadyCheckedIn: true,
data: {
event: {
id: event.id,
slug: event.slug,
name: event.name,
location: event.location,
startDate: event.startDate,
endDate: event.endDate,
},
joinedAt: existingParticipant.joinedAt,
},
});
}
// Add user to event participants
const participant = await prisma.eventParticipant.create({
data: {
userId,
eventId: event.id,
},
});
// Update participants count
await prisma.event.update({
where: { id: event.id },
data: {
participantsCount: {
increment: 1,
},
},
});
res.json({
success: true,
alreadyCheckedIn: false,
data: {
event: {
id: event.id,
slug: event.slug,
name: event.name,
location: event.location,
startDate: event.startDate,
endDate: event.endDate,
},
joinedAt: participant.joinedAt,
},
});
} catch (error) {
next(error);
}
});
// GET /api/events/:slug/details - Get event details with check-in token and participants
router.get('/:slug/details', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
// Find event by slug with participants
const event = await prisma.event.findUnique({
where: { slug },
include: {
participants: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
firstName: true,
lastName: true,
},
},
},
orderBy: {
joinedAt: 'desc',
},
},
},
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Find or create check-in token (on-demand generation)
let checkinToken = await prisma.eventCheckinToken.findUnique({
where: { eventId: event.id },
});
if (!checkinToken) {
checkinToken = await prisma.eventCheckinToken.create({
data: {
eventId: event.id,
},
});
}
// Calculate valid dates (startDate - 1 day to endDate + 1 day)
const validFrom = new Date(event.startDate);
validFrom.setDate(validFrom.getDate() - 1);
const validUntil = new Date(event.endDate);
validUntil.setDate(validUntil.getDate() + 1);
// Build check-in URL
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:8080';
const checkinUrl = `${baseUrl}/events/checkin/${checkinToken.token}`;
res.json({
success: true,
data: {
event: {
id: event.id,
slug: event.slug,
name: event.name,
location: event.location,
startDate: event.startDate,
endDate: event.endDate,
description: event.description,
},
checkin: {
token: checkinToken.token,
url: checkinUrl,
validFrom,
validUntil,
},
participants: event.participants.map(p => ({
userId: p.user.id,
username: p.user.username,
avatar: p.user.avatar,
firstName: p.user.firstName,
lastName: p.user.lastName,
joinedAt: p.joinedAt,
})),
stats: {
totalParticipants: event.participants.length,
},
},
});
} catch (error) {
next(error);
}
});
// DELETE /api/events/:slug/leave - Leave an event (remove from participants)
router.delete('/:slug/leave', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const userId = req.user.id;
// Find event by slug
const event = await prisma.event.findUnique({
where: { slug },
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Check if user is participating
const participant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: {
userId,
eventId: event.id,
},
},
});
if (!participant) {
return res.status(400).json({
success: false,
error: 'You are not a participant of this event',
});
}
// Remove from participants
await prisma.eventParticipant.delete({
where: {
userId_eventId: {
userId,
eventId: event.id,
},
},
});
// Update participants count
await prisma.event.update({
where: { id: event.id },
data: {
participantsCount: {
decrement: 1,
},
},
});
res.json({
success: true,
message: 'Successfully left the event',
});
} catch (error) {
next(error);
}
});
module.exports = router; module.exports = router;

View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
@@ -3400,6 +3401,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",

View File

@@ -7,6 +7,8 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage';
import ResetPasswordPage from './pages/ResetPasswordPage'; import ResetPasswordPage from './pages/ResetPasswordPage';
import EventsPage from './pages/EventsPage'; import EventsPage from './pages/EventsPage';
import EventChatPage from './pages/EventChatPage'; import EventChatPage from './pages/EventChatPage';
import EventDetailsPage from './pages/EventDetailsPage';
import EventCheckinPage from './pages/EventCheckinPage';
import MatchChatPage from './pages/MatchChatPage'; import MatchChatPage from './pages/MatchChatPage';
import RatePartnerPage from './pages/RatePartnerPage'; import RatePartnerPage from './pages/RatePartnerPage';
import HistoryPage from './pages/HistoryPage'; import HistoryPage from './pages/HistoryPage';
@@ -100,6 +102,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/events/:slug/details"
element={
<ProtectedRoute>
<EventDetailsPage />
</ProtectedRoute>
}
/>
<Route
path="/events/checkin/:token"
element={
<ProtectedRoute>
<EventCheckinPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/matches/:matchId/chat" path="/matches/:matchId/chat"
element={ element={

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Send, UserPlus, Loader2 } from 'lucide-react'; import { Send, UserPlus, Loader2, LogOut, AlertTriangle } from 'lucide-react';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
import { eventsAPI } from '../services/api'; import { eventsAPI } from '../services/api';
@@ -18,6 +18,8 @@ const EventChatPage = () => {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null); const messagesContainerRef = useRef(null);
@@ -173,6 +175,28 @@ const EventChatPage = () => {
}, 1000); }, 1000);
}; };
const handleLeaveEvent = async () => {
try {
setIsLeaving(true);
await eventsAPI.leave(slug);
// Disconnect socket
const socket = getSocket();
if (socket) {
socket.emit('leave_event_room');
}
// Redirect to events page
navigate('/events');
} catch (error) {
console.error('Failed to leave event:', error);
alert('Failed to leave event. Please try again.');
} finally {
setIsLeaving(false);
setShowLeaveModal(false);
}
};
// Infinite scroll - detect scroll to top // Infinite scroll - detect scroll to top
useEffect(() => { useEffect(() => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
@@ -344,7 +368,67 @@ const EventChatPage = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Leave Event Button */}
<div className="mt-4 flex justify-center">
<button
onClick={() => setShowLeaveModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
<LogOut size={16} />
Leave Event
</button>
</div>
</div> </div>
{/* Leave Confirmation Modal */}
{showLeaveModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="text-red-600" size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Leave Event?</h3>
<p className="text-sm text-gray-600">This action cannot be undone</p>
</div>
</div>
<p className="text-gray-700 mb-6">
Are you sure you want to leave <strong>{event.name}</strong>?
You will need to scan the QR code again to rejoin.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowLeaveModal(false)}
disabled={isLeaving}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleLeaveEvent}
disabled={isLeaving}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLeaving ? (
<>
<Loader2 className="animate-spin" size={16} />
Leaving...
</>
) : (
<>
<LogOut size={16} />
Leave Event
</>
)}
</button>
</div>
</div>
</div>
)}
</div> </div>
</Layout> </Layout>
); );

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import { CheckCircle, XCircle, Calendar, MapPin, Loader } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { eventsAPI } from '../services/api';
export default function EventCheckinPage() {
const { token } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [alreadyCheckedIn, setAlreadyCheckedIn] = useState(false);
const [eventData, setEventData] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
// Redirect to login if not authenticated
if (!user) {
navigate('/login', {
state: { from: location.pathname },
replace: true,
});
return;
}
// Perform check-in
performCheckin();
}, [user]);
const performCheckin = async () => {
try {
setLoading(true);
const response = await eventsAPI.checkin(token);
if (response.success) {
setSuccess(true);
setAlreadyCheckedIn(response.alreadyCheckedIn);
setEventData(response.data.event);
}
} catch (err) {
console.error('Check-in error:', err);
setError(err.message || 'Failed to check in to event');
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-6">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
<Loader className="animate-spin mx-auto mb-4 text-primary-600" size={48} />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Checking you in...
</h2>
<p className="text-gray-600">Please wait while we process your check-in</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 to-red-100 flex items-center justify-center p-6">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center mb-6">
<XCircle className="mx-auto mb-4 text-red-600" size={64} />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Check-in Failed
</h2>
<p className="text-gray-600">{error}</p>
</div>
<div className="space-y-3">
<Link
to="/events"
className="block w-full bg-primary-600 text-white px-6 py-3 rounded-lg hover:bg-primary-700 transition-colors text-center font-medium"
>
Back to Events
</Link>
</div>
</div>
</div>
);
}
if (success && eventData) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center p-6">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center mb-6">
<CheckCircle className="mx-auto mb-4 text-green-600" size={64} />
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{alreadyCheckedIn ? 'Already Checked In!' : 'Check-in Successful!'}
</h2>
<p className="text-gray-600">
{alreadyCheckedIn
? 'You are already a participant of this event'
: 'You have successfully joined this event'}
</p>
</div>
{/* Event Info */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-lg text-gray-900 mb-3">
{eventData.name}
</h3>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<MapPin size={16} className="text-gray-400" />
<span>{eventData.location}</span>
</div>
<div className="flex items-center gap-2">
<Calendar size={16} className="text-gray-400" />
<span>
{formatDate(eventData.startDate)} - {formatDate(eventData.endDate)}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="space-y-3">
<Link
to={`/events/${eventData.slug}/chat`}
className="block w-full 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>
<Link
to="/events"
className="block w-full border border-gray-300 px-6 py-3 rounded-lg hover:bg-gray-50 transition-colors text-center font-medium text-gray-700"
>
Browse Other Events
</Link>
</div>
</div>
</div>
);
}
return null;
}

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

View File

@@ -199,6 +199,25 @@ export const eventsAPI = {
const data = await fetchAPI(`/events/${slug}/messages?${params}`); const data = await fetchAPI(`/events/${slug}/messages?${params}`);
return data; return data;
}, },
async getDetails(slug) {
const data = await fetchAPI(`/events/${slug}/details`);
return data;
},
async checkin(token) {
const data = await fetchAPI(`/events/checkin/${token}`, {
method: 'POST',
});
return data;
},
async leave(slug) {
const data = await fetchAPI(`/events/${slug}/leave`, {
method: 'DELETE',
});
return data;
},
}; };
export { ApiError }; export { ApiError };