diff --git a/backend/prisma/migrations/20251114125544_add_event_checkin_tokens/migration.sql b/backend/prisma/migrations/20251114125544_add_event_checkin_tokens/migration.sql new file mode 100644 index 0000000..58d5bb3 --- /dev/null +++ b/backend/prisma/migrations/20251114125544_add_event_checkin_tokens/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f9c09df..75325fc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -75,10 +75,24 @@ model Event { chatRooms ChatRoom[] matches Match[] participants EventParticipant[] + checkinToken EventCheckinToken? @@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) model ChatRoom { id Int @id @default(autoincrement()) diff --git a/backend/src/__tests__/events-checkin.test.js b/backend/src/__tests__/events-checkin.test.js new file mode 100644 index 0000000..e11d478 --- /dev/null +++ b/backend/src/__tests__/events-checkin.test.js @@ -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); + }); + }); +}); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 4aaf7bf..9ad4dfe 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 435f7c3..9f9460d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "lucide-react": "^0.553.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5", @@ -3400,6 +3401,15 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index c6bf5e8..3176b41 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "lucide-react": "^0.553.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.5", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1472153..8f3b04a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,8 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'; import ResetPasswordPage from './pages/ResetPasswordPage'; import EventsPage from './pages/EventsPage'; import EventChatPage from './pages/EventChatPage'; +import EventDetailsPage from './pages/EventDetailsPage'; +import EventCheckinPage from './pages/EventCheckinPage'; import MatchChatPage from './pages/MatchChatPage'; import RatePartnerPage from './pages/RatePartnerPage'; import HistoryPage from './pages/HistoryPage'; @@ -100,6 +102,22 @@ function App() { } /> + + + + } + /> + + + + } + /> { const [isConnected, setIsConnected] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [hasMore, setHasMore] = useState(true); + const [showLeaveModal, setShowLeaveModal] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); @@ -173,6 +175,28 @@ const EventChatPage = () => { }, 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 useEffect(() => { const container = messagesContainerRef.current; @@ -344,7 +368,67 @@ const EventChatPage = () => { + + {/* Leave Event Button */} +
+ +
+ + {/* Leave Confirmation Modal */} + {showLeaveModal && ( +
+
+
+
+ +
+
+

Leave Event?

+

This action cannot be undone

+
+
+ +

+ Are you sure you want to leave {event.name}? + You will need to scan the QR code again to rejoin. +

+ +
+ + +
+
+
+ )} ); diff --git a/frontend/src/pages/EventCheckinPage.jsx b/frontend/src/pages/EventCheckinPage.jsx new file mode 100644 index 0000000..e919810 --- /dev/null +++ b/frontend/src/pages/EventCheckinPage.jsx @@ -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 ( +
+
+ +

+ Checking you in... +

+

Please wait while we process your check-in

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

+ Check-in Failed +

+

{error}

+
+ +
+ + Back to Events + +
+
+
+ ); + } + + if (success && eventData) { + return ( +
+
+
+ +

+ {alreadyCheckedIn ? 'Already Checked In!' : 'Check-in Successful!'} +

+

+ {alreadyCheckedIn + ? 'You are already a participant of this event' + : 'You have successfully joined this event'} +

+
+ + {/* Event Info */} +
+

+ {eventData.name} +

+
+
+ + {eventData.location} +
+
+ + + {formatDate(eventData.startDate)} - {formatDate(eventData.endDate)} + +
+
+
+ + {/* Actions */} +
+ + Go to Event Chat + + + Browse Other Events + +
+
+
+ ); + } + + return null; +} diff --git a/frontend/src/pages/EventDetailsPage.jsx b/frontend/src/pages/EventDetailsPage.jsx new file mode 100644 index 0000000..2a176f1 --- /dev/null +++ b/frontend/src/pages/EventDetailsPage.jsx @@ -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 ( + +
+
+
+

Loading event details...

+
+
+
+ ); + } + + if (error) { + return ( + +
+
+

{error}

+ + Back to Events + +
+
+
+ ); + } + + if (!eventDetails) { + return null; + } + + const { event, checkin, participants, stats } = eventDetails; + + return ( + +
+ {/* Header */} +
+ + ← Back to Events + +

{event.name}

+
+
+ + {event.location} +
+
+ + {formatDate(event.startDate)} - {formatDate(event.endDate)} +
+
+
+ +
+ {/* QR Code Section */} +
+

+ + Event Check-in QR Code +

+ + {/* QR Code Display */} +
+ +
+ + {/* Check-in URL */} +
+ +
+ + +
+
+ + {/* Valid Dates */} +
+

Valid Period

+

+ {formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)} +

+ {process.env.NODE_ENV === 'development' && ( +

+ ⚠️ Development mode: Date validation disabled +

+ )} +
+
+ + {/* Participants Section */} +
+

+ + Participants ({stats.totalParticipants}) +

+ + {participants.length === 0 ? ( +
+ +

No participants yet

+

Share the QR code to get started!

+
+ ) : ( +
+ {participants.map((participant) => ( +
+ {/* Avatar */} +
+ {participant.avatar ? ( + {participant.username} + ) : ( + {participant.username.charAt(0).toUpperCase()} + )} +
+ + {/* User Info */} +
+

+ {participant.firstName && participant.lastName + ? `${participant.firstName} ${participant.lastName}` + : participant.username} +

+

@{participant.username}

+
+ + {/* Joined Date */} +
+ {new Date(participant.joinedAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+
+ + {/* Action Buttons */} +
+ + Go to Event Chat + + +
+ + {/* Print Styles */} + +
+
+ ); +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index cb9f43a..ff245f9 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -199,6 +199,25 @@ export const eventsAPI = { const data = await fetchAPI(`/events/${slug}/messages?${params}`); 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 };