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:
Radosław Gierwiało
2025-11-14 19:22:23 +01:00
parent eaf80c6c6f
commit 4a3e32f3b6
8 changed files with 1102 additions and 9 deletions

View File

@@ -79,7 +79,7 @@ app.use('/api/events', require('./routes/events'));
app.use('/api/wsdc', require('./routes/wsdc'));
app.use('/api/divisions', require('./routes/divisions'));
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'));
// 404 handler

View 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;

View File

@@ -63,6 +63,11 @@ function initializeSocket(httpServer) {
io.on('connection', (socket) => {
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
socket.on('join_event_room', async ({ slug }) => {
try {

View File

@@ -10,6 +10,7 @@ import EventChatPage from './pages/EventChatPage';
import EventDetailsPage from './pages/EventDetailsPage';
import EventCheckinPage from './pages/EventCheckinPage';
import MatchChatPage from './pages/MatchChatPage';
import MatchesPage from './pages/MatchesPage';
import RatePartnerPage from './pages/RatePartnerPage';
import HistoryPage from './pages/HistoryPage';
import ProfilePage from './pages/ProfilePage';
@@ -118,6 +119,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/matches"
element={
<ProtectedRoute>
<MatchesPage />
</ProtectedRoute>
}
/>
<Route
path="/matches/:matchId/chat"
element={

View File

@@ -1,16 +1,60 @@
import { Link, useNavigate } from 'react-router-dom';
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 { user, logout } = useAuth();
const navigate = useNavigate();
const [pendingMatchesCount, setPendingMatchesCount] = useState(0);
const handleLogout = () => {
logout();
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;
return (
@@ -25,6 +69,19 @@ const Navbar = () => {
</div>
<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
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"

View File

@@ -4,7 +4,7 @@ import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext';
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X } from 'lucide-react';
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';
const EventChatPage = () => {
@@ -252,12 +252,29 @@ const EventChatPage = () => {
}
};
const handleMatchWith = (userId) => {
// TODO: Implement match request
alert(`Match request sent to user!`);
setTimeout(() => {
navigate(`/matches/1/chat`);
}, 1000);
const handleMatchWith = async (userId) => {
try {
const result = await matchesAPI.createMatch(userId, slug);
// Show success message
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 = () => {

View 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;

View File

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