2025-11-21 21:12:25 +01:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { useNavigate, Link } from 'react-router-dom';
|
2025-11-21 21:27:03 +01:00
|
|
|
import toast from 'react-hot-toast';
|
2025-11-21 21:12:25 +01:00
|
|
|
import Layout from '../components/layout/Layout';
|
|
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
|
import { dashboardAPI, matchesAPI } from '../services/api';
|
|
|
|
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
|
|
|
|
import HeatBadges from '../components/heats/HeatBadges';
|
2025-11-21 21:37:05 +01:00
|
|
|
import { DashboardSkeleton } from '../components/common/Skeleton';
|
2025-11-21 21:12:25 +01:00
|
|
|
import {
|
|
|
|
|
Calendar,
|
|
|
|
|
MapPin,
|
|
|
|
|
Users,
|
|
|
|
|
MessageCircle,
|
|
|
|
|
Video,
|
|
|
|
|
Star,
|
|
|
|
|
Check,
|
|
|
|
|
X,
|
|
|
|
|
Loader2,
|
|
|
|
|
Clock,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Inbox,
|
|
|
|
|
Send,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const DashboardPage = () => {
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [data, setData] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
const [processingMatchId, setProcessingMatchId] = useState(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadDashboard();
|
|
|
|
|
|
|
|
|
|
// Connect to socket for real-time updates
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
if (token && user) {
|
|
|
|
|
connectSocket(token, user.id);
|
|
|
|
|
|
|
|
|
|
const socket = getSocket();
|
|
|
|
|
if (socket) {
|
2025-11-21 21:27:03 +01:00
|
|
|
socket.on('match_request_received', handleMatchRequest);
|
|
|
|
|
socket.on('match_accepted', handleMatchAccepted);
|
2025-11-21 21:12:25 +01:00
|
|
|
socket.on('match_cancelled', handleRealtimeUpdate);
|
|
|
|
|
socket.on('new_message', handleRealtimeUpdate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
const socket = getSocket();
|
|
|
|
|
if (socket) {
|
2025-11-21 21:27:03 +01:00
|
|
|
socket.off('match_request_received', handleMatchRequest);
|
|
|
|
|
socket.off('match_accepted', handleMatchAccepted);
|
2025-11-21 21:12:25 +01:00
|
|
|
socket.off('match_cancelled', handleRealtimeUpdate);
|
|
|
|
|
socket.off('new_message', handleRealtimeUpdate);
|
|
|
|
|
}
|
|
|
|
|
disconnectSocket();
|
|
|
|
|
};
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
2025-11-21 21:27:03 +01:00
|
|
|
const handleMatchRequest = (data) => {
|
|
|
|
|
toast.success(`New match request from ${data.requesterUsername || 'someone'}!`, {
|
|
|
|
|
icon: '📨',
|
|
|
|
|
});
|
|
|
|
|
loadDashboard();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMatchAccepted = (data) => {
|
|
|
|
|
toast.success('Match accepted! You can now chat.', {
|
|
|
|
|
icon: '🎉',
|
|
|
|
|
});
|
|
|
|
|
loadDashboard();
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-21 21:12:25 +01:00
|
|
|
const loadDashboard = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const result = await dashboardAPI.getData();
|
|
|
|
|
setData(result);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to load dashboard:', err);
|
|
|
|
|
setError('Failed to load dashboard');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRealtimeUpdate = () => {
|
|
|
|
|
loadDashboard();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAcceptMatch = async (matchSlug) => {
|
|
|
|
|
try {
|
|
|
|
|
setProcessingMatchId(matchSlug);
|
|
|
|
|
await matchesAPI.acceptMatch(matchSlug);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.success('Match accepted! You can now chat.', { icon: '🎉' });
|
2025-11-21 21:12:25 +01:00
|
|
|
await loadDashboard();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to accept match:', err);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.error('Failed to accept match. Please try again.');
|
2025-11-21 21:12:25 +01:00
|
|
|
} finally {
|
|
|
|
|
setProcessingMatchId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRejectMatch = async (matchSlug) => {
|
|
|
|
|
if (!confirm('Are you sure you want to decline this request?')) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setProcessingMatchId(matchSlug);
|
|
|
|
|
await matchesAPI.deleteMatch(matchSlug);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.success('Request declined.');
|
2025-11-21 21:12:25 +01:00
|
|
|
await loadDashboard();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to reject match:', err);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.error('Failed to decline request. Please try again.');
|
2025-11-21 21:12:25 +01:00
|
|
|
} finally {
|
|
|
|
|
setProcessingMatchId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancelRequest = async (matchSlug) => {
|
|
|
|
|
if (!confirm('Are you sure you want to cancel this request?')) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setProcessingMatchId(matchSlug);
|
|
|
|
|
await matchesAPI.deleteMatch(matchSlug);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.success('Request cancelled.');
|
2025-11-21 21:12:25 +01:00
|
|
|
await loadDashboard();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to cancel request:', err);
|
2025-11-21 21:27:03 +01:00
|
|
|
toast.error('Failed to cancel request. Please try again.');
|
2025-11-21 21:12:25 +01:00
|
|
|
} finally {
|
|
|
|
|
setProcessingMatchId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<Layout>
|
2025-11-21 21:37:05 +01:00
|
|
|
<DashboardSkeleton />
|
2025-11-21 21:12:25 +01:00
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<Layout>
|
|
|
|
|
<div className="max-w-5xl mx-auto">
|
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-md p-4 text-red-700">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { activeEvents, activeMatches, matchRequests } = data || {};
|
|
|
|
|
const hasIncoming = matchRequests?.incoming?.length > 0;
|
|
|
|
|
const hasOutgoing = matchRequests?.outgoing?.length > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Layout>
|
|
|
|
|
<div className="max-w-5xl mx-auto">
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
|
|
|
<p className="text-gray-600 mt-1">
|
|
|
|
|
Welcome back, {user?.firstName || user?.username}!
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Active Events Section */}
|
|
|
|
|
<section className="mb-8">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
|
|
|
|
<Calendar className="w-5 h-5 text-primary-600" />
|
|
|
|
|
Your Events
|
|
|
|
|
</h2>
|
|
|
|
|
<Link
|
|
|
|
|
to="/events"
|
|
|
|
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
Browse all <ChevronRight className="w-4 h-4" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{activeEvents?.length > 0 ? (
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
{activeEvents.map((event) => (
|
|
|
|
|
<EventCard key={event.id} event={event} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<EmptyState
|
|
|
|
|
icon={<Calendar className="w-12 h-12 text-gray-300" />}
|
|
|
|
|
title="No active events"
|
|
|
|
|
description="Check in at an event to start connecting with other dancers!"
|
|
|
|
|
action={
|
|
|
|
|
<Link
|
|
|
|
|
to="/events"
|
|
|
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Browse Events <ChevronRight className="w-4 h-4" />
|
|
|
|
|
</Link>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Active Matches Section */}
|
|
|
|
|
<section className="mb-8">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
|
|
|
|
<MessageCircle className="w-5 h-5 text-primary-600" />
|
|
|
|
|
Active Matches
|
|
|
|
|
</h2>
|
|
|
|
|
<Link
|
|
|
|
|
to="/matches"
|
|
|
|
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
View all <ChevronRight className="w-4 h-4" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{activeMatches?.length > 0 ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{activeMatches.map((match) => (
|
|
|
|
|
<MatchCard key={match.id} match={match} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<EmptyState
|
|
|
|
|
icon={<Users className="w-12 h-12 text-gray-300" />}
|
|
|
|
|
title="No active matches"
|
|
|
|
|
description="Join an event chat and send match requests to start collaborating!"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Match Requests Section */}
|
|
|
|
|
{(hasIncoming || hasOutgoing) && (
|
|
|
|
|
<section className="mb-8">
|
|
|
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2 mb-4">
|
|
|
|
|
<Inbox className="w-5 h-5 text-primary-600" />
|
|
|
|
|
Match Requests
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
{/* Incoming Requests */}
|
|
|
|
|
{hasIncoming && (
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
|
|
|
|
|
<Inbox className="w-4 h-4" />
|
|
|
|
|
Incoming ({matchRequests.incoming.length})
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{matchRequests.incoming.map((request) => (
|
|
|
|
|
<IncomingRequestCard
|
|
|
|
|
key={request.id}
|
|
|
|
|
request={request}
|
|
|
|
|
onAccept={handleAcceptMatch}
|
|
|
|
|
onReject={handleRejectMatch}
|
|
|
|
|
processing={processingMatchId === request.slug}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Outgoing Requests */}
|
|
|
|
|
{hasOutgoing && (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3 flex items-center gap-2">
|
|
|
|
|
<Send className="w-4 h-4" />
|
|
|
|
|
Outgoing ({matchRequests.outgoing.length})
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{matchRequests.outgoing.map((request) => (
|
|
|
|
|
<OutgoingRequestCard
|
|
|
|
|
key={request.id}
|
|
|
|
|
request={request}
|
|
|
|
|
onCancel={handleCancelRequest}
|
|
|
|
|
processing={processingMatchId === request.slug}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Event Card Component
|
|
|
|
|
const EventCard = ({ event }) => {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
|
const formatDateRange = (start, end) => {
|
|
|
|
|
const startDate = new Date(start);
|
|
|
|
|
const endDate = new Date(end);
|
|
|
|
|
const options = { month: 'short', day: 'numeric' };
|
|
|
|
|
return `${startDate.toLocaleDateString('en-US', options)} - ${endDate.toLocaleDateString('en-US', options)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 mb-3">
|
|
|
|
|
<h3 className="font-semibold text-gray-900">{event.name}</h3>
|
|
|
|
|
<span className="text-xs px-2 py-1 bg-primary-100 text-primary-700 rounded-full">
|
|
|
|
|
Joined
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5 text-sm text-gray-600 mb-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
|
|
|
|
<span>{event.location}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
|
|
|
|
<span>{formatDateRange(event.startDate, event.endDate)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Users className="w-4 h-4 flex-shrink-0" />
|
|
|
|
|
<span>{event.participantsCount} participants</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{event.myHeats?.length > 0 && (
|
|
|
|
|
<div className="mb-3">
|
|
|
|
|
<p className="text-xs text-gray-500 mb-1">Your heats:</p>
|
|
|
|
|
<HeatBadges heats={event.myHeats} maxVisible={4} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => navigate(`/events/${event.slug}/chat`)}
|
|
|
|
|
className="w-full flex items-center justify-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" />
|
|
|
|
|
Enter Chat
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Match Card Component
|
|
|
|
|
const MatchCard = ({ match }) => {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { partner, event, videoExchange, ratings } = match;
|
|
|
|
|
|
2025-11-21 21:21:58 +01:00
|
|
|
// Can rate when video exchange is complete and user hasn't rated yet
|
|
|
|
|
const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
|
|
|
|
|
|
2025-11-21 21:12:25 +01:00
|
|
|
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 gap-4">
|
|
|
|
|
{/* Avatar */}
|
|
|
|
|
<Link to={`/${partner.username}`} className="flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
|
|
|
|
alt={partner.username}
|
|
|
|
|
className="w-12 h-12 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<Link to={`/${partner.username}`}>
|
|
|
|
|
<h3 className="font-semibold text-gray-900 hover:text-primary-600 transition-colors">
|
|
|
|
|
{partner.firstName && partner.lastName
|
|
|
|
|
? `${partner.firstName} ${partner.lastName}`
|
|
|
|
|
: partner.username}
|
|
|
|
|
</h3>
|
|
|
|
|
</Link>
|
|
|
|
|
<p className="text-sm text-gray-500">@{partner.username}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-sm text-gray-600 mt-1">{event.name}</p>
|
|
|
|
|
|
|
|
|
|
{/* Status Indicators */}
|
|
|
|
|
<div className="flex flex-wrap gap-3 mt-3 text-sm">
|
|
|
|
|
<VideoStatus exchange={videoExchange} />
|
|
|
|
|
<RatingStatus ratings={ratings} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-21 21:21:58 +01:00
|
|
|
{/* Actions */}
|
|
|
|
|
<div className="flex-shrink-0 flex flex-col gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => navigate(`/matches/${match.slug}/chat`)}
|
|
|
|
|
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" />
|
|
|
|
|
Chat
|
|
|
|
|
</button>
|
|
|
|
|
{canRate && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => navigate(`/matches/${match.slug}/rate`)}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 border border-amber-500 text-amber-600 rounded-md hover:bg-amber-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Star className="w-4 h-4" />
|
|
|
|
|
Rate
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-11-21 21:12:25 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Video Exchange Status
|
|
|
|
|
const VideoStatus = ({ exchange }) => {
|
|
|
|
|
const sent = exchange?.sentByMe;
|
|
|
|
|
const received = exchange?.receivedFromPartner;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Video className="w-4 h-4 text-gray-400" />
|
|
|
|
|
<span className={sent ? 'text-green-600' : 'text-gray-400'}>
|
|
|
|
|
{sent ? <Check className="w-3 h-3 inline" /> : <Clock className="w-3 h-3 inline" />} Sent
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-gray-300">|</span>
|
|
|
|
|
<span className={received ? 'text-green-600' : 'text-gray-400'}>
|
|
|
|
|
{received ? <Check className="w-3 h-3 inline" /> : <Clock className="w-3 h-3 inline" />} Received
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Rating Status
|
|
|
|
|
const RatingStatus = ({ ratings }) => {
|
|
|
|
|
const ratedByMe = ratings?.ratedByMe;
|
|
|
|
|
const ratedByPartner = ratings?.ratedByPartner;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Star className="w-4 h-4 text-gray-400" />
|
|
|
|
|
<span className={ratedByMe ? 'text-green-600' : 'text-gray-400'}>
|
|
|
|
|
{ratedByMe ? <Check className="w-3 h-3 inline" /> : <Clock className="w-3 h-3 inline" />} You
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-gray-300">|</span>
|
|
|
|
|
<span className={ratedByPartner ? 'text-green-600' : 'text-gray-400'}>
|
|
|
|
|
{ratedByPartner ? <Check className="w-3 h-3 inline" /> : <Clock className="w-3 h-3 inline" />} Partner
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Incoming Request Card
|
|
|
|
|
const IncomingRequestCard = ({ request, onAccept, onReject, processing }) => {
|
|
|
|
|
const { requester, event, requesterHeats } = request;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-amber-200 p-4">
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
<Link to={`/${requester.username}`} className="flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={requester.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${requester.username}`}
|
|
|
|
|
alt={requester.username}
|
|
|
|
|
className="w-10 h-10 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<Link to={`/${requester.username}`}>
|
|
|
|
|
<h4 className="font-medium text-gray-900 hover:text-primary-600 transition-colors">
|
|
|
|
|
{requester.firstName && requester.lastName
|
|
|
|
|
? `${requester.firstName} ${requester.lastName}`
|
|
|
|
|
: requester.username}
|
|
|
|
|
</h4>
|
|
|
|
|
</Link>
|
|
|
|
|
<p className="text-sm text-gray-500">@{requester.username}</p>
|
|
|
|
|
<p className="text-sm text-gray-600 mt-1">{event.name}</p>
|
|
|
|
|
|
|
|
|
|
{requesterHeats?.length > 0 && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<HeatBadges heats={requesterHeats} maxVisible={3} compact />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onAccept(request.slug)}
|
|
|
|
|
disabled={processing}
|
|
|
|
|
className="p-2 bg-green-600 text-white rounded-full hover:bg-green-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
title="Accept"
|
|
|
|
|
>
|
|
|
|
|
{processing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onReject(request.slug)}
|
|
|
|
|
disabled={processing}
|
|
|
|
|
className="p-2 bg-red-600 text-white rounded-full hover:bg-red-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
title="Decline"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Outgoing Request Card
|
|
|
|
|
const OutgoingRequestCard = ({ request, onCancel, processing }) => {
|
|
|
|
|
const { recipient, event } = request;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-blue-200 p-4">
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
<Link to={`/${recipient.username}`} className="flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={recipient.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${recipient.username}`}
|
|
|
|
|
alt={recipient.username}
|
|
|
|
|
className="w-10 h-10 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<Link to={`/${recipient.username}`}>
|
|
|
|
|
<h4 className="font-medium text-gray-900 hover:text-primary-600 transition-colors">
|
|
|
|
|
{recipient.firstName && recipient.lastName
|
|
|
|
|
? `${recipient.firstName} ${recipient.lastName}`
|
|
|
|
|
: recipient.username}
|
|
|
|
|
</h4>
|
|
|
|
|
</Link>
|
|
|
|
|
<p className="text-sm text-gray-500">@{recipient.username}</p>
|
|
|
|
|
<p className="text-sm text-gray-600 mt-1">{event.name}</p>
|
|
|
|
|
<p className="text-xs text-blue-600 mt-2 flex items-center gap-1">
|
|
|
|
|
<Clock className="w-3 h-3" />
|
|
|
|
|
Waiting for response...
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onCancel(request.slug)}
|
|
|
|
|
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 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{processing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Cancel'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Empty State Component
|
|
|
|
|
const EmptyState = ({ icon, title, description, action }) => {
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
|
|
|
|
<div className="flex justify-center mb-3">{icon}</div>
|
|
|
|
|
<h3 className="font-medium text-gray-900 mb-1">{title}</h3>
|
|
|
|
|
<p className="text-sm text-gray-600 mb-4">{description}</p>
|
|
|
|
|
{action}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default DashboardPage;
|