feat: implement Phase 2 - Matches API with real-time notifications
Backend changes:
- Add matches API routes (POST, GET, PUT, DELETE)
- Create/accept/reject match requests
- Auto-create private chat rooms on match acceptance
- Socket.IO notifications for match events (received, accepted, cancelled)
- Users join personal rooms (user_{id}) for notifications
Frontend changes:
- Add MatchesPage component with inbox UI
- Matches navigation link with notification badge
- Real-time match request count updates
- Accept/reject match functionality
- Filter matches by status (all/pending/accepted)
- Integrate match requests in EventChatPage (UserPlus button)
Features:
- Send match requests to event participants
- Accept incoming match requests
- Real-time notifications via Socket.IO
- Automatic private chat room creation
- Match status tracking (pending/accepted/completed)
- Authorization checks (only participants can match)
- Duplicate match prevention
This commit is contained in:
@@ -79,7 +79,7 @@ app.use('/api/events', require('./routes/events'));
|
|||||||
app.use('/api/wsdc', require('./routes/wsdc'));
|
app.use('/api/wsdc', require('./routes/wsdc'));
|
||||||
app.use('/api/divisions', require('./routes/divisions'));
|
app.use('/api/divisions', require('./routes/divisions'));
|
||||||
app.use('/api/competition-types', require('./routes/competitionTypes'));
|
app.use('/api/competition-types', require('./routes/competitionTypes'));
|
||||||
// app.use('/api/matches', require('./routes/matches'));
|
app.use('/api/matches', require('./routes/matches'));
|
||||||
// app.use('/api/ratings', require('./routes/ratings'));
|
// app.use('/api/ratings', require('./routes/ratings'));
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
618
backend/src/routes/matches.js
Normal file
618
backend/src/routes/matches.js
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { prisma } = require('../utils/db');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const { getIO } = require('../socket');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/matches - Create a match request
|
||||||
|
router.post('/', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { targetUserId, eventSlug } = req.body;
|
||||||
|
const requesterId = req.user.id;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!targetUserId || !eventSlug) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'targetUserId and eventSlug are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requesterId === targetUserId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot create match with yourself',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find event by slug
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { slug: eventSlug },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Event not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if both users are participants
|
||||||
|
const [requesterParticipant, targetParticipant] = await Promise.all([
|
||||||
|
prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_eventId: {
|
||||||
|
userId: requesterId,
|
||||||
|
eventId: event.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_eventId: {
|
||||||
|
userId: targetUserId,
|
||||||
|
eventId: event.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requesterParticipant) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'You must be a participant of this event',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetParticipant) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Target user is not a participant of this event',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if match already exists (in either direction)
|
||||||
|
const existingMatch = await prisma.match.findFirst({
|
||||||
|
where: {
|
||||||
|
eventId: event.id,
|
||||||
|
OR: [
|
||||||
|
{ user1Id: requesterId, user2Id: targetUserId },
|
||||||
|
{ user1Id: targetUserId, user2Id: requesterId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMatch) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Match already exists with this user',
|
||||||
|
match: existingMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create match
|
||||||
|
const match = await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
user1Id: requesterId,
|
||||||
|
user2Id: targetUserId,
|
||||||
|
eventId: event.id,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit socket event to target user
|
||||||
|
try {
|
||||||
|
const io = getIO();
|
||||||
|
const targetSocketRoom = `user_${targetUserId}`;
|
||||||
|
io.to(targetSocketRoom).emit('match_request_received', {
|
||||||
|
matchId: match.id,
|
||||||
|
from: {
|
||||||
|
id: match.user1.id,
|
||||||
|
username: match.user1.username,
|
||||||
|
avatar: match.user1.avatar,
|
||||||
|
firstName: match.user1.firstName,
|
||||||
|
lastName: match.user1.lastName,
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
slug: match.event.slug,
|
||||||
|
name: match.event.name,
|
||||||
|
},
|
||||||
|
createdAt: match.createdAt,
|
||||||
|
});
|
||||||
|
} catch (socketError) {
|
||||||
|
console.error('Failed to emit match request notification:', socketError);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: match,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Handle unique constraint violation
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Match already exists with this user for this event',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/matches - List matches for current user (optionally filtered by event)
|
||||||
|
router.get('/', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { eventSlug, status } = req.query;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where = {
|
||||||
|
OR: [
|
||||||
|
{ user1Id: userId },
|
||||||
|
{ user2Id: userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by event if provided
|
||||||
|
if (eventSlug) {
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { slug: eventSlug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Event not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
where.eventId = event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status if provided
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch matches
|
||||||
|
const matches = await prisma.match.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
location: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform matches to include partner info
|
||||||
|
const transformedMatches = matches.map(match => {
|
||||||
|
const isUser1 = match.user1Id === userId;
|
||||||
|
const partner = isUser1 ? match.user2 : match.user1;
|
||||||
|
const isInitiator = match.user1Id === userId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.id,
|
||||||
|
partner: {
|
||||||
|
id: partner.id,
|
||||||
|
username: partner.username,
|
||||||
|
avatar: partner.avatar,
|
||||||
|
firstName: partner.firstName,
|
||||||
|
lastName: partner.lastName,
|
||||||
|
},
|
||||||
|
event: match.event,
|
||||||
|
status: match.status,
|
||||||
|
roomId: match.roomId,
|
||||||
|
isInitiator,
|
||||||
|
createdAt: match.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
count: transformedMatches.length,
|
||||||
|
data: transformedMatches,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/matches/:id - Get specific match
|
||||||
|
router.get('/:id', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
include: {
|
||||||
|
user1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
location: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Match not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'You are not authorized to view this match',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform match data
|
||||||
|
const isUser1 = match.user1Id === userId;
|
||||||
|
const partner = isUser1 ? match.user2 : match.user1;
|
||||||
|
const isInitiator = match.user1Id === userId;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: match.id,
|
||||||
|
partner: {
|
||||||
|
id: partner.id,
|
||||||
|
username: partner.username,
|
||||||
|
avatar: partner.avatar,
|
||||||
|
firstName: partner.firstName,
|
||||||
|
lastName: partner.lastName,
|
||||||
|
},
|
||||||
|
event: match.event,
|
||||||
|
status: match.status,
|
||||||
|
roomId: match.roomId,
|
||||||
|
isInitiator,
|
||||||
|
createdAt: match.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/matches/:id/accept - Accept a pending match
|
||||||
|
router.put('/:id/accept', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Find match
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
include: {
|
||||||
|
user1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Match not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization - only user2 can accept
|
||||||
|
if (match.user2Id !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Only the match recipient can accept',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if (match.status !== 'pending') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Match is already ${match.status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create private chat room and update match in transaction
|
||||||
|
const updatedMatch = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create private chat room
|
||||||
|
const chatRoom = await tx.chatRoom.create({
|
||||||
|
data: {
|
||||||
|
type: 'private',
|
||||||
|
eventId: match.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update match status and link to chat room
|
||||||
|
const updated = await tx.match.update({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
data: {
|
||||||
|
status: 'accepted',
|
||||||
|
roomId: chatRoom.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user1: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user2: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit socket event to both users
|
||||||
|
try {
|
||||||
|
const io = getIO();
|
||||||
|
const user1SocketRoom = `user_${match.user1Id}`;
|
||||||
|
const user2SocketRoom = `user_${match.user2Id}`;
|
||||||
|
|
||||||
|
const notification = {
|
||||||
|
matchId: updatedMatch.id,
|
||||||
|
roomId: updatedMatch.roomId,
|
||||||
|
event: {
|
||||||
|
slug: updatedMatch.event.slug,
|
||||||
|
name: updatedMatch.event.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
io.to(user1SocketRoom).emit('match_accepted', {
|
||||||
|
...notification,
|
||||||
|
partner: {
|
||||||
|
id: updatedMatch.user2.id,
|
||||||
|
username: updatedMatch.user2.username,
|
||||||
|
avatar: updatedMatch.user2.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.to(user2SocketRoom).emit('match_accepted', {
|
||||||
|
...notification,
|
||||||
|
partner: {
|
||||||
|
id: updatedMatch.user1.id,
|
||||||
|
username: updatedMatch.user1.username,
|
||||||
|
avatar: updatedMatch.user1.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (socketError) {
|
||||||
|
console.error('Failed to emit match accepted notification:', socketError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform response
|
||||||
|
const isUser1 = updatedMatch.user1Id === userId;
|
||||||
|
const partner = isUser1 ? updatedMatch.user2 : updatedMatch.user1;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: updatedMatch.id,
|
||||||
|
partner: {
|
||||||
|
id: partner.id,
|
||||||
|
username: partner.username,
|
||||||
|
avatar: partner.avatar,
|
||||||
|
firstName: partner.firstName,
|
||||||
|
lastName: partner.lastName,
|
||||||
|
},
|
||||||
|
event: updatedMatch.event,
|
||||||
|
status: updatedMatch.status,
|
||||||
|
roomId: updatedMatch.roomId,
|
||||||
|
isInitiator: isUser1,
|
||||||
|
createdAt: updatedMatch.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/matches/:id - Reject or cancel a match
|
||||||
|
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Find match
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
include: {
|
||||||
|
event: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Match not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization - both users can delete
|
||||||
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'You are not authorized to delete this match',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot delete completed matches
|
||||||
|
if (match.status === 'completed') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot delete completed matches',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete match (will cascade delete chat room if exists)
|
||||||
|
await prisma.match.delete({
|
||||||
|
where: { id: parseInt(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit socket event to the other user
|
||||||
|
try {
|
||||||
|
const io = getIO();
|
||||||
|
const otherUserId = match.user1Id === userId ? match.user2Id : match.user1Id;
|
||||||
|
const otherUserSocketRoom = `user_${otherUserId}`;
|
||||||
|
|
||||||
|
io.to(otherUserSocketRoom).emit('match_cancelled', {
|
||||||
|
matchId: match.id,
|
||||||
|
event: {
|
||||||
|
slug: match.event.slug,
|
||||||
|
name: match.event.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (socketError) {
|
||||||
|
console.error('Failed to emit match cancelled notification:', socketError);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Match deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -63,6 +63,11 @@ function initializeSocket(httpServer) {
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
|
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
|
||||||
|
|
||||||
|
// Join user's personal room for notifications
|
||||||
|
const userRoom = `user_${socket.user.id}`;
|
||||||
|
socket.join(userRoom);
|
||||||
|
console.log(`📬 ${socket.user.username} joined personal room: ${userRoom}`);
|
||||||
|
|
||||||
// Join event room
|
// Join event room
|
||||||
socket.on('join_event_room', async ({ slug }) => {
|
socket.on('join_event_room', async ({ slug }) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import EventChatPage from './pages/EventChatPage';
|
|||||||
import EventDetailsPage from './pages/EventDetailsPage';
|
import EventDetailsPage from './pages/EventDetailsPage';
|
||||||
import EventCheckinPage from './pages/EventCheckinPage';
|
import EventCheckinPage from './pages/EventCheckinPage';
|
||||||
import MatchChatPage from './pages/MatchChatPage';
|
import MatchChatPage from './pages/MatchChatPage';
|
||||||
|
import MatchesPage from './pages/MatchesPage';
|
||||||
import RatePartnerPage from './pages/RatePartnerPage';
|
import RatePartnerPage from './pages/RatePartnerPage';
|
||||||
import HistoryPage from './pages/HistoryPage';
|
import HistoryPage from './pages/HistoryPage';
|
||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
@@ -118,6 +119,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/matches"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MatchesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/matches/:matchId/chat"
|
path="/matches/:matchId/chat"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,16 +1,60 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { Video, LogOut, User, History } from 'lucide-react';
|
import { Video, LogOut, User, History, Users } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { matchesAPI } from '../../services/api';
|
||||||
|
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [pendingMatchesCount, setPendingMatchesCount] = useState(0);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
loadPendingMatches();
|
||||||
|
|
||||||
|
// Connect to socket for real-time updates
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
connectSocket(token, user.id);
|
||||||
|
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) {
|
||||||
|
// Listen for match notifications
|
||||||
|
socket.on('match_request_received', () => loadPendingMatches());
|
||||||
|
socket.on('match_accepted', () => loadPendingMatches());
|
||||||
|
socket.on('match_cancelled', () => loadPendingMatches());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) {
|
||||||
|
socket.off('match_request_received');
|
||||||
|
socket.off('match_accepted');
|
||||||
|
socket.off('match_cancelled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const loadPendingMatches = async () => {
|
||||||
|
try {
|
||||||
|
const result = await matchesAPI.getMatches(null, 'pending');
|
||||||
|
// Only count incoming requests (where user is not the initiator)
|
||||||
|
const incomingCount = (result.data || []).filter(m => !m.isInitiator).length;
|
||||||
|
setPendingMatchesCount(incomingCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load pending matches:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -25,6 +69,19 @@ const Navbar = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
to="/matches"
|
||||||
|
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100 relative"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>Matches</span>
|
||||||
|
{pendingMatchesCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-semibold">
|
||||||
|
{pendingMatchesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/history"
|
to="/history"
|
||||||
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Layout from '../components/layout/Layout';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X } from 'lucide-react';
|
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X } from 'lucide-react';
|
||||||
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
||||||
import { eventsAPI, heatsAPI } from '../services/api';
|
import { eventsAPI, heatsAPI, matchesAPI } from '../services/api';
|
||||||
import HeatsBanner from '../components/heats/HeatsBanner';
|
import HeatsBanner from '../components/heats/HeatsBanner';
|
||||||
|
|
||||||
const EventChatPage = () => {
|
const EventChatPage = () => {
|
||||||
@@ -252,12 +252,29 @@ const EventChatPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMatchWith = (userId) => {
|
const handleMatchWith = async (userId) => {
|
||||||
// TODO: Implement match request
|
try {
|
||||||
alert(`Match request sent to user!`);
|
const result = await matchesAPI.createMatch(userId, slug);
|
||||||
setTimeout(() => {
|
|
||||||
navigate(`/matches/1/chat`);
|
// Show success message
|
||||||
}, 1000);
|
alert(`Match request sent successfully! The user will be notified.`);
|
||||||
|
|
||||||
|
// Optional: Navigate to matches page or refresh matches list
|
||||||
|
// For now, we just show a success message
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send match request:', error);
|
||||||
|
|
||||||
|
// Show appropriate error message
|
||||||
|
if (error.status === 400 && error.message.includes('already exists')) {
|
||||||
|
alert('You already have a match request with this user.');
|
||||||
|
} else if (error.status === 403) {
|
||||||
|
alert('You must be a participant of this event to send match requests.');
|
||||||
|
} else if (error.status === 404) {
|
||||||
|
alert('Event not found.');
|
||||||
|
} else {
|
||||||
|
alert('Failed to send match request. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHeatsSave = () => {
|
const handleHeatsSave = () => {
|
||||||
|
|||||||
345
frontend/src/pages/MatchesPage.jsx
Normal file
345
frontend/src/pages/MatchesPage.jsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../components/layout/Layout';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { matchesAPI } from '../services/api';
|
||||||
|
import { MessageCircle, Check, X, Loader2, Users, Calendar, MapPin } from 'lucide-react';
|
||||||
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
||||||
|
|
||||||
|
const MatchesPage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('all'); // 'all', 'pending', 'accepted'
|
||||||
|
const [processingMatchId, setProcessingMatchId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMatches();
|
||||||
|
|
||||||
|
// Connect to socket for real-time updates
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token && user) {
|
||||||
|
connectSocket(token, user.id);
|
||||||
|
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) {
|
||||||
|
// Listen for match notifications
|
||||||
|
socket.on('match_request_received', handleMatchRequestReceived);
|
||||||
|
socket.on('match_accepted', handleMatchAccepted);
|
||||||
|
socket.on('match_cancelled', handleMatchCancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (socket) {
|
||||||
|
socket.off('match_request_received', handleMatchRequestReceived);
|
||||||
|
socket.off('match_accepted', handleMatchAccepted);
|
||||||
|
socket.off('match_cancelled', handleMatchCancelled);
|
||||||
|
}
|
||||||
|
disconnectSocket();
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const loadMatches = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await matchesAPI.getMatches();
|
||||||
|
setMatches(result.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load matches:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatchRequestReceived = (data) => {
|
||||||
|
// Reload matches to show new request
|
||||||
|
loadMatches();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatchAccepted = (data) => {
|
||||||
|
// Reload matches to update status
|
||||||
|
loadMatches();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatchCancelled = (data) => {
|
||||||
|
// Remove cancelled match from list
|
||||||
|
setMatches(prev => prev.filter(m => m.id !== data.matchId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccept = async (matchId) => {
|
||||||
|
try {
|
||||||
|
setProcessingMatchId(matchId);
|
||||||
|
await matchesAPI.acceptMatch(matchId);
|
||||||
|
|
||||||
|
// Reload matches
|
||||||
|
await loadMatches();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
alert('Match accepted! You can now chat with your partner.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to accept match:', error);
|
||||||
|
alert('Failed to accept match. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setProcessingMatchId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (matchId) => {
|
||||||
|
if (!confirm('Are you sure you want to reject this match request?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessingMatchId(matchId);
|
||||||
|
await matchesAPI.deleteMatch(matchId);
|
||||||
|
|
||||||
|
// Remove from list
|
||||||
|
setMatches(prev => prev.filter(m => m.id !== matchId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reject match:', error);
|
||||||
|
alert('Failed to reject match. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setProcessingMatchId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChat = (match) => {
|
||||||
|
if (match.status === 'accepted' && match.roomId) {
|
||||||
|
navigate(`/matches/${match.id}/chat`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter matches based on selected filter
|
||||||
|
const filteredMatches = matches.filter(match => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
return match.status === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separate pending incoming matches (where user is recipient)
|
||||||
|
const pendingIncoming = filteredMatches.filter(m => m.status === 'pending' && !m.isInitiator);
|
||||||
|
const otherMatches = filteredMatches.filter(m => !(m.status === 'pending' && !m.isInitiator));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Match Requests</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Manage your dance partner connections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm p-1 mb-6 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filter === 'all'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({matches.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('pending')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filter === 'pending'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pending ({matches.filter(m => m.status === 'pending').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('accepted')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filter === 'accepted'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active ({matches.filter(m => m.status === 'accepted').length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Pending Incoming Requests Section */}
|
||||||
|
{pendingIncoming.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-amber-600" />
|
||||||
|
Incoming Requests
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pendingIncoming.map(match => (
|
||||||
|
<MatchCard
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onReject={handleReject}
|
||||||
|
onOpenChat={handleOpenChat}
|
||||||
|
processing={processingMatchId === match.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other Matches */}
|
||||||
|
{otherMatches.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
{pendingIncoming.length > 0 ? 'Other Matches' : 'Your Matches'}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{otherMatches.map(match => (
|
||||||
|
<MatchCard
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onReject={handleReject}
|
||||||
|
onOpenChat={handleOpenChat}
|
||||||
|
processing={processingMatchId === match.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredMatches.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No matches found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{filter === 'all'
|
||||||
|
? 'You have no match requests yet. Connect with other dancers at events!'
|
||||||
|
: `You have no ${filter} matches.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
|
||||||
|
const isIncoming = !match.isInitiator && match.status === 'pending';
|
||||||
|
const isOutgoing = match.isInitiator && match.status === 'pending';
|
||||||
|
const isAccepted = match.status === 'accepted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<img
|
||||||
|
src={match.partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${match.partner.username}`}
|
||||||
|
alt={match.partner.username}
|
||||||
|
className="w-12 h-12 rounded-full"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
{match.partner.firstName && match.partner.lastName
|
||||||
|
? `${match.partner.firstName} ${match.partner.lastName}`
|
||||||
|
: match.partner.username}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">@{match.partner.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>{match.event.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{match.event.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isIncoming && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full font-medium">
|
||||||
|
Incoming Request
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOutgoing && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||||
|
Sent Request
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isAccepted && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded-full font-medium">
|
||||||
|
Active Match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{isIncoming && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onAccept(match.id)}
|
||||||
|
disabled={processing}
|
||||||
|
className="p-2 bg-green-600 text-white rounded-full hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Accept"
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onReject(match.id)}
|
||||||
|
disabled={processing}
|
||||||
|
className="p-2 bg-red-600 text-white rounded-full hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOutgoing && (
|
||||||
|
<button
|
||||||
|
onClick={() => onReject(match.id)}
|
||||||
|
disabled={processing}
|
||||||
|
className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Cancel Request
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAccepted && (
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenChat(match)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
Open Chat
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchesPage;
|
||||||
@@ -264,4 +264,46 @@ export const heatsAPI = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches API (Phase 2)
|
||||||
|
export const matchesAPI = {
|
||||||
|
async createMatch(targetUserId, eventSlug) {
|
||||||
|
const data = await fetchAPI('/matches', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ targetUserId, eventSlug }),
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMatches(eventSlug = null, status = null) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (eventSlug) params.append('eventSlug', eventSlug);
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const endpoint = queryString ? `/matches?${queryString}` : '/matches';
|
||||||
|
|
||||||
|
const data = await fetchAPI(endpoint);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMatch(matchId) {
|
||||||
|
const data = await fetchAPI(`/matches/${matchId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptMatch(matchId) {
|
||||||
|
const data = await fetchAPI(`/matches/${matchId}/accept`, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMatch(matchId) {
|
||||||
|
const data = await fetchAPI(`/matches/${matchId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
|
|||||||
Reference in New Issue
Block a user