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:
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|||||||
263
backend/src/__tests__/events-checkin.test.js
Normal file
263
backend/src/__tests__/events-checkin.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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,8 +368,68 @@ 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>
|
</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>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
154
frontend/src/pages/EventCheckinPage.jsx
Normal file
154
frontend/src/pages/EventCheckinPage.jsx
Normal 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;
|
||||||
|
}
|
||||||
248
frontend/src/pages/EventDetailsPage.jsx
Normal file
248
frontend/src/pages/EventDetailsPage.jsx
Normal 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">
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user