Replaced all confirm() dialogs with reusable ConfirmationModal component for better UX. Modal dialogs provide clearer context, visual consistency, and prevent accidental confirmations. Changes: - MatchesPage: Reject match request confirmation modal - DashboardPage: Decline and cancel request confirmation modals - ContactMessagesPage: Delete message confirmation modal All modals support loading states during async operations and provide clear action descriptions with destructive action styling.
374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
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 { DashboardSkeleton } from '../components/common/Skeleton';
|
|
import EmptyState from '../components/common/EmptyState';
|
|
import ConfirmationModal from '../components/modals/ConfirmationModal';
|
|
import {
|
|
DashboardEventCard,
|
|
DashboardMatchCard,
|
|
RecordingSummaryCard,
|
|
IncomingRequestCard,
|
|
OutgoingRequestCard,
|
|
} from '../components/dashboard';
|
|
import {
|
|
Calendar,
|
|
Users,
|
|
MessageCircle,
|
|
ChevronRight,
|
|
Inbox,
|
|
Send,
|
|
Video,
|
|
} from 'lucide-react';
|
|
|
|
const DashboardPage = () => {
|
|
const { user } = useAuth();
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showDeclineModal, setShowDeclineModal] = useState(false);
|
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
|
const [matchToProcess, setMatchToProcess] = useState(null);
|
|
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) {
|
|
socket.on('match_request_received', handleMatchRequest);
|
|
socket.on('match_accepted', handleMatchAccepted);
|
|
socket.on('match_cancelled', handleRealtimeUpdate);
|
|
socket.on('new_message', handleRealtimeUpdate);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
const socket = getSocket();
|
|
if (socket) {
|
|
socket.off('match_request_received', handleMatchRequest);
|
|
socket.off('match_accepted', handleMatchAccepted);
|
|
socket.off('match_cancelled', handleRealtimeUpdate);
|
|
socket.off('new_message', handleRealtimeUpdate);
|
|
}
|
|
disconnectSocket();
|
|
};
|
|
}, [user]);
|
|
|
|
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();
|
|
};
|
|
|
|
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);
|
|
toast.success('Match accepted! You can now chat.', { icon: '🎉' });
|
|
await loadDashboard();
|
|
} catch (err) {
|
|
console.error('Failed to accept match:', err);
|
|
toast.error('Failed to accept match. Please try again.');
|
|
} finally {
|
|
setProcessingMatchId(null);
|
|
}
|
|
};
|
|
|
|
const handleRejectMatch = (matchSlug) => {
|
|
setMatchToProcess(matchSlug);
|
|
setShowDeclineModal(true);
|
|
};
|
|
|
|
const confirmRejectMatch = async () => {
|
|
if (!matchToProcess) return;
|
|
|
|
try {
|
|
setProcessingMatchId(matchToProcess);
|
|
await matchesAPI.deleteMatch(matchToProcess);
|
|
toast.success('Request declined.');
|
|
await loadDashboard();
|
|
} catch (err) {
|
|
console.error('Failed to reject match:', err);
|
|
toast.error('Failed to decline request. Please try again.');
|
|
} finally {
|
|
setProcessingMatchId(null);
|
|
setShowDeclineModal(false);
|
|
setMatchToProcess(null);
|
|
}
|
|
};
|
|
|
|
const handleCancelRequest = (matchSlug) => {
|
|
setMatchToProcess(matchSlug);
|
|
setShowCancelModal(true);
|
|
};
|
|
|
|
const confirmCancelRequest = async () => {
|
|
if (!matchToProcess) return;
|
|
|
|
try {
|
|
setProcessingMatchId(matchToProcess);
|
|
await matchesAPI.deleteMatch(matchToProcess);
|
|
toast.success('Request cancelled.');
|
|
await loadDashboard();
|
|
} catch (err) {
|
|
console.error('Failed to cancel request:', err);
|
|
toast.error('Failed to cancel request. Please try again.');
|
|
} finally {
|
|
setProcessingMatchId(null);
|
|
setShowCancelModal(false);
|
|
setMatchToProcess(null);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Layout>
|
|
<DashboardSkeleton />
|
|
</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;
|
|
|
|
// Filter events with recording suggestions
|
|
const eventsWithRecordings = activeEvents?.filter(
|
|
(event) =>
|
|
event.recordingSuggestions &&
|
|
(event.recordingSuggestions.toBeRecorded.length > 0 ||
|
|
event.recordingSuggestions.toRecord.length > 0)
|
|
) || [];
|
|
|
|
return (
|
|
<Layout pageTitle="Dashboard">
|
|
<div className="max-w-5xl mx-auto">
|
|
<div className="hidden lg:block 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) => (
|
|
<DashboardEventCard 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>
|
|
|
|
{/* Recording Assignments Section */}
|
|
{eventsWithRecordings.length > 0 && (
|
|
<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">
|
|
<Video className="w-5 h-5 text-primary-600" />
|
|
Recording Assignments
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{eventsWithRecordings.map((event) => (
|
|
<RecordingSummaryCard key={event.id} event={event} />
|
|
))}
|
|
</div>
|
|
</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) => (
|
|
<DashboardMatchCard 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>
|
|
|
|
{/* Decline Confirmation Modal */}
|
|
<ConfirmationModal
|
|
isOpen={showDeclineModal}
|
|
onClose={() => {
|
|
setShowDeclineModal(false);
|
|
setMatchToProcess(null);
|
|
}}
|
|
onConfirm={confirmRejectMatch}
|
|
title="Decline Match Request"
|
|
message="Are you sure you want to decline this request? This action cannot be undone."
|
|
confirmText="Decline"
|
|
cancelText="Cancel"
|
|
isLoading={processingMatchId === matchToProcess}
|
|
loadingText="Declining..."
|
|
/>
|
|
|
|
{/* Cancel Confirmation Modal */}
|
|
<ConfirmationModal
|
|
isOpen={showCancelModal}
|
|
onClose={() => {
|
|
setShowCancelModal(false);
|
|
setMatchToProcess(null);
|
|
}}
|
|
onConfirm={confirmCancelRequest}
|
|
title="Cancel Match Request"
|
|
message="Are you sure you want to cancel this request? This action cannot be undone."
|
|
confirmText="Cancel Request"
|
|
cancelText="Keep Request"
|
|
isLoading={processingMatchId === matchToProcess}
|
|
loadingText="Cancelling..."
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default DashboardPage;
|