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:
Radosław Gierwiało
2025-11-21 21:27:03 +01:00
parent 4187157b94
commit 87b0079b84
5 changed files with 88 additions and 12 deletions

View File

@@ -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",

View File

@@ -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"
}, },

View File

@@ -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

View File

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

View File

@@ -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);