feat(frontend): add toast notifications for dashboard actions
- Install react-hot-toast library - Add Toaster component to App.jsx - Show success/error toasts for match accept/reject/cancel - Show toasts for real-time match events - Update tests with toast mocks
This commit is contained in:
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"socket.io-client": "^4.8.1"
|
"socket.io-client": "^4.8.1"
|
||||||
},
|
},
|
||||||
@@ -4168,7 +4169,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
@@ -5402,6 +5402,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -7274,6 +7283,23 @@
|
|||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"socket.io-client": "^4.8.1"
|
"socket.io-client": "^4.8.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
@@ -70,6 +71,30 @@ function App() {
|
|||||||
{/* PWA Install Prompt */}
|
{/* PWA Install Prompt */}
|
||||||
<InstallPWA />
|
<InstallPWA />
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#333',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
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';
|
||||||
@@ -39,8 +40,8 @@ const DashboardPage = () => {
|
|||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on('match_request_received', handleRealtimeUpdate);
|
socket.on('match_request_received', handleMatchRequest);
|
||||||
socket.on('match_accepted', handleRealtimeUpdate);
|
socket.on('match_accepted', handleMatchAccepted);
|
||||||
socket.on('match_cancelled', handleRealtimeUpdate);
|
socket.on('match_cancelled', handleRealtimeUpdate);
|
||||||
socket.on('new_message', handleRealtimeUpdate);
|
socket.on('new_message', handleRealtimeUpdate);
|
||||||
}
|
}
|
||||||
@@ -49,8 +50,8 @@ const DashboardPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.off('match_request_received', handleRealtimeUpdate);
|
socket.off('match_request_received', handleMatchRequest);
|
||||||
socket.off('match_accepted', handleRealtimeUpdate);
|
socket.off('match_accepted', handleMatchAccepted);
|
||||||
socket.off('match_cancelled', handleRealtimeUpdate);
|
socket.off('match_cancelled', handleRealtimeUpdate);
|
||||||
socket.off('new_message', handleRealtimeUpdate);
|
socket.off('new_message', handleRealtimeUpdate);
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,20 @@ const DashboardPage = () => {
|
|||||||
};
|
};
|
||||||
}, [user]);
|
}, [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 () => {
|
const loadDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -79,10 +94,11 @@ const DashboardPage = () => {
|
|||||||
try {
|
try {
|
||||||
setProcessingMatchId(matchSlug);
|
setProcessingMatchId(matchSlug);
|
||||||
await matchesAPI.acceptMatch(matchSlug);
|
await matchesAPI.acceptMatch(matchSlug);
|
||||||
|
toast.success('Match accepted! You can now chat.', { icon: '🎉' });
|
||||||
await loadDashboard();
|
await loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to accept match:', err);
|
console.error('Failed to accept match:', err);
|
||||||
alert('Failed to accept match. Please try again.');
|
toast.error('Failed to accept match. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingMatchId(null);
|
setProcessingMatchId(null);
|
||||||
}
|
}
|
||||||
@@ -94,10 +110,11 @@ const DashboardPage = () => {
|
|||||||
try {
|
try {
|
||||||
setProcessingMatchId(matchSlug);
|
setProcessingMatchId(matchSlug);
|
||||||
await matchesAPI.deleteMatch(matchSlug);
|
await matchesAPI.deleteMatch(matchSlug);
|
||||||
|
toast.success('Request declined.');
|
||||||
await loadDashboard();
|
await loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to reject match:', err);
|
console.error('Failed to reject match:', err);
|
||||||
alert('Failed to decline request. Please try again.');
|
toast.error('Failed to decline request. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingMatchId(null);
|
setProcessingMatchId(null);
|
||||||
}
|
}
|
||||||
@@ -109,10 +126,11 @@ const DashboardPage = () => {
|
|||||||
try {
|
try {
|
||||||
setProcessingMatchId(matchSlug);
|
setProcessingMatchId(matchSlug);
|
||||||
await matchesAPI.deleteMatch(matchSlug);
|
await matchesAPI.deleteMatch(matchSlug);
|
||||||
|
toast.success('Request cancelled.');
|
||||||
await loadDashboard();
|
await loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to cancel request:', err);
|
console.error('Failed to cancel request:', err);
|
||||||
alert('Failed to cancel request. Please try again.');
|
toast.error('Failed to cancel request. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingMatchId(null);
|
setProcessingMatchId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import '@testing-library/jest-dom';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
// Mock the modules
|
// Mock the modules
|
||||||
|
vi.mock('react-hot-toast', () => ({
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../contexts/AuthContext', () => ({
|
vi.mock('../../contexts/AuthContext', () => ({
|
||||||
useAuth: () => ({
|
useAuth: () => ({
|
||||||
user: { id: 1, username: 'testuser', firstName: 'Test' },
|
user: { id: 1, username: 'testuser', firstName: 'Test' },
|
||||||
@@ -407,10 +414,9 @@ describe('DashboardPage', () => {
|
|||||||
expect(screen.getByText('John Lead')).toBeInTheDocument();
|
expect(screen.getByText('John Lead')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the accept button (green button with checkmark)
|
// Find the accept button by title attribute
|
||||||
const acceptButtons = screen.getAllByRole('button');
|
const acceptButton = document.querySelector('button[title="Accept"]');
|
||||||
const acceptButton = acceptButtons.find(btn => btn.title === 'Accept');
|
expect(acceptButton).toBeTruthy();
|
||||||
expect(acceptButton).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(acceptButton);
|
fireEvent.click(acceptButton);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user