refactor(frontend): extract DashboardPage into components
Split DashboardPage (578 lines) into focused components: - DashboardEventCard: event card with chat access - DashboardMatchCard: match card with status indicators - MatchRequestCards: incoming/outgoing request cards - EmptyState: reusable empty state component (in common/) DashboardPage now 295 lines (-49%)
This commit is contained in:
15
frontend/src/components/common/EmptyState.jsx
Normal file
15
frontend/src/components/common/EmptyState.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generic empty state component for displaying when no data is available
|
||||
*/
|
||||
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 EmptyState;
|
||||
66
frontend/src/components/dashboard/DashboardEventCard.jsx
Normal file
66
frontend/src/components/dashboard/DashboardEventCard.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Calendar, MapPin, Users, MessageCircle } from 'lucide-react';
|
||||
import HeatBadges from '../heats/HeatBadges';
|
||||
|
||||
/**
|
||||
* Event card for dashboard - shows joined event with quick access to chat
|
||||
*/
|
||||
const DashboardEventCard = ({ 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>
|
||||
{event.onlineCount > 0 && (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{event.onlineCount} online
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardEventCard;
|
||||
121
frontend/src/components/dashboard/DashboardMatchCard.jsx
Normal file
121
frontend/src/components/dashboard/DashboardMatchCard.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { MessageCircle, Video, Star, Check, Clock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Video exchange status indicator
|
||||
*/
|
||||
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 indicator
|
||||
*/
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Match card for dashboard - shows active match with chat access
|
||||
*/
|
||||
const DashboardMatchCard = ({ match }) => {
|
||||
const navigate = useNavigate();
|
||||
const { partner, event, videoExchange, ratings, unreadCount } = match;
|
||||
|
||||
// Can rate when video exchange is complete and user hasn't rated yet
|
||||
const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
|
||||
|
||||
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 relative">
|
||||
<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"
|
||||
/>
|
||||
{unreadCount > 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">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardMatchCard;
|
||||
106
frontend/src/components/dashboard/MatchRequestCards.jsx
Normal file
106
frontend/src/components/dashboard/MatchRequestCards.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Check, X, Loader2, Clock } from 'lucide-react';
|
||||
import HeatBadges from '../heats/HeatBadges';
|
||||
|
||||
/**
|
||||
* Incoming match request card - shows request from another user
|
||||
*/
|
||||
export 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 match request card - shows pending request sent by user
|
||||
*/
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
3
frontend/src/components/dashboard/index.js
Normal file
3
frontend/src/components/dashboard/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as DashboardEventCard } from './DashboardEventCard';
|
||||
export { default as DashboardMatchCard } from './DashboardMatchCard';
|
||||
export { IncomingRequestCard, OutgoingRequestCard } from './MatchRequestCards';
|
||||
@@ -1,23 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
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';
|
||||
import { DashboardSkeleton } from '../components/common/Skeleton';
|
||||
import EmptyState from '../components/common/EmptyState';
|
||||
import {
|
||||
DashboardEventCard,
|
||||
DashboardMatchCard,
|
||||
IncomingRequestCard,
|
||||
OutgoingRequestCard,
|
||||
} from '../components/dashboard';
|
||||
import {
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
MessageCircle,
|
||||
Video,
|
||||
Star,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Inbox,
|
||||
Send,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
|
||||
const DashboardPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -189,7 +187,7 @@ const DashboardPage = () => {
|
||||
{activeEvents?.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{activeEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
<DashboardEventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -227,7 +225,7 @@ const DashboardPage = () => {
|
||||
{activeMatches?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{activeMatches.map((match) => (
|
||||
<MatchCard key={match.id} match={match} />
|
||||
<DashboardMatchCard key={match.id} match={match} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -294,285 +292,4 @@ const DashboardPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 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>
|
||||
{event.onlineCount > 0 && (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
{event.onlineCount} online
|
||||
</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, unreadCount } = match;
|
||||
|
||||
// Can rate when video exchange is complete and user hasn't rated yet
|
||||
const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
|
||||
|
||||
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 relative">
|
||||
<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"
|
||||
/>
|
||||
{unreadCount > 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">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user