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:
Radosław Gierwiało
2025-11-23 22:08:16 +01:00
parent bddcf5f4f9
commit 8e17c10353
6 changed files with 321 additions and 293 deletions

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

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

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

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

View File

@@ -0,0 +1,3 @@
export { default as DashboardEventCard } from './DashboardEventCard';
export { default as DashboardMatchCard } from './DashboardMatchCard';
export { IncomingRequestCard, OutgoingRequestCard } from './MatchRequestCards';

View File

@@ -1,23 +1,22 @@
import { useState, useEffect } from 'react'; 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 toast from 'react-hot-toast';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { dashboardAPI, matchesAPI } from '../services/api'; import { dashboardAPI, matchesAPI } from '../services/api';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
import HeatBadges from '../components/heats/HeatBadges';
import { DashboardSkeleton } from '../components/common/Skeleton'; import { DashboardSkeleton } from '../components/common/Skeleton';
import EmptyState from '../components/common/EmptyState';
import {
DashboardEventCard,
DashboardMatchCard,
IncomingRequestCard,
OutgoingRequestCard,
} from '../components/dashboard';
import { import {
Calendar, Calendar,
MapPin,
Users, Users,
MessageCircle, MessageCircle,
Video,
Star,
Check,
X,
Loader2,
Clock,
ChevronRight, ChevronRight,
Inbox, Inbox,
Send, Send,
@@ -25,7 +24,6 @@ import {
const DashboardPage = () => { const DashboardPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -189,7 +187,7 @@ const DashboardPage = () => {
{activeEvents?.length > 0 ? ( {activeEvents?.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{activeEvents.map((event) => ( {activeEvents.map((event) => (
<EventCard key={event.id} event={event} /> <DashboardEventCard key={event.id} event={event} />
))} ))}
</div> </div>
) : ( ) : (
@@ -227,7 +225,7 @@ const DashboardPage = () => {
{activeMatches?.length > 0 ? ( {activeMatches?.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{activeMatches.map((match) => ( {activeMatches.map((match) => (
<MatchCard key={match.id} match={match} /> <DashboardMatchCard key={match.id} match={match} />
))} ))}
</div> </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; export default DashboardPage;