refactor: convert frontend from submodule to true monorepo
Convert frontend from Git submodule to a regular monorepo directory for simplified development workflow. Changes: - Remove frontend submodule tracking (mode 160000 gitlink) - Add all frontend source files directly to main repository - Remove frontend/.git directory - Update CLAUDE.md to clarify true monorepo structure - Update Frontend Architecture documentation (React Router v6, Socket.IO, Leaflet, ErrorBoundary) Benefits of Monorepo: - Single git clone for entire project - Unified commit history - Simpler CI/CD pipeline - Easier for new developers - No submodule sync issues - Atomic commits across frontend and backend Frontend Files Added: - All React components (MapView, ErrorBoundary, TaskList, SocialFeed, etc.) - Context providers (AuthContext, SocketContext) - Complete test suite with MSW - Dependencies and configuration files Branch Cleanup: - Using 'main' as default branch (develop deleted) - Frontend no longer has separate Git history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -29,15 +29,21 @@ The backend follows a standard Express MVC pattern:
|
||||
- `middleware/auth.js`: JWT authentication middleware using `x-auth-token` header
|
||||
|
||||
### Frontend Architecture
|
||||
React SPA using React Router v5:
|
||||
React SPA using React Router v6:
|
||||
- `App.js`: Main router with client-side routing
|
||||
- `context/AuthContext.js`: Global authentication state management
|
||||
- `components/`: Feature components (MapView, TaskList, SocialFeed, Profile, Events, Rewards, Premium, Login, Register, Navbar)
|
||||
- `context/SocketContext.js`: Socket.IO real-time connection management
|
||||
- `components/`: Feature components (MapView, TaskList, SocialFeed, Profile, Events, Rewards, Premium, Login, Register, Navbar, ErrorBoundary)
|
||||
- Real-time updates via Socket.IO for events, posts, and tasks
|
||||
- Interactive map with Leaflet for street visualization
|
||||
- Comprehensive error handling with ErrorBoundary
|
||||
|
||||
Authentication flow: JWT tokens stored in localStorage and sent via `x-auth-token` header on all API requests.
|
||||
|
||||
Frontend proxies API requests to `http://localhost:5000` in development.
|
||||
|
||||
**Note:** This is a true monorepo - both frontend and backend are tracked in the same Git repository for simplified development and deployment.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend
|
||||
|
||||
-1
Submodule frontend deleted from e3a8eacec4
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
||||
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
+2887
File diff suppressed because it is too large
Load Diff
Generated
+18250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^6.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^1.0.3",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"proxy": "http://localhost:5000",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"test:coverage": "react-scripts test --coverage --watchAll=false",
|
||||
"test:watch": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx}",
|
||||
"!src/index.js",
|
||||
"!src/reportWebVitals.js",
|
||||
"!src/setupTests.js",
|
||||
"!src/mocks/**"
|
||||
],
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 50,
|
||||
"functions": 60,
|
||||
"lines": 60,
|
||||
"statements": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"msw": "^2.11.6"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Adopt-a-Street</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
import AuthProvider from "./context/AuthContext";
|
||||
import SocketProvider from "./context/SocketContext";
|
||||
import Login from "./components/Login";
|
||||
import Register from "./components/Register";
|
||||
import MapView from "./components/MapView";
|
||||
import TaskList from "./components/TaskList";
|
||||
import SocialFeed from "./components/SocialFeed";
|
||||
import Profile from "./components/Profile";
|
||||
import Events from "./components/Events";
|
||||
import Rewards from "./components/Rewards";
|
||||
import Premium from "./components/Premium";
|
||||
import Navbar from "./components/Navbar";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/tasks" element={<TaskList />} />
|
||||
<Route path="/feed" element={<SocialFeed />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/rewards" element={<Rewards />} />
|
||||
<Route path="/premium" element={<Premium />} />
|
||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
</Router>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '../App';
|
||||
import AuthProvider from '../context/AuthContext';
|
||||
|
||||
// Mock react-toastify to avoid toast errors
|
||||
jest.mock('react-toastify', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
ToastContainer: () => null,
|
||||
}));
|
||||
|
||||
// Mock Leaflet map components to avoid rendering issues
|
||||
jest.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div>TileLayer</div>,
|
||||
Marker: () => <div>Marker</div>,
|
||||
Popup: ({ children }) => <div>{children}</div>,
|
||||
useMap: () => ({
|
||||
flyTo: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-leaflet-cluster', () => ({
|
||||
default: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Socket.IO
|
||||
jest.mock('socket.io-client', () => {
|
||||
return jest.fn(() => ({
|
||||
on: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
off: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Authentication Flow Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderApp = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Registration Flow', () => {
|
||||
it('should allow user to register and access protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
// Should be on the login page initially (or map if not authenticated)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate to register page if there's a link
|
||||
const registerLinks = screen.queryAllByText(/register/i);
|
||||
if (registerLinks.length > 0) {
|
||||
fireEvent.click(registerLinks[0]);
|
||||
}
|
||||
|
||||
// Fill out registration form
|
||||
await waitFor(() => {
|
||||
const nameInput = screen.queryByPlaceholderText(/name/i);
|
||||
if (nameInput) {
|
||||
fireEvent.change(nameInput, { target: { value: 'Test User' } });
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
fireEvent.click(submitButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login Flow', () => {
|
||||
it('should allow user to login and access protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
// Wait for initial loading
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Look for login form
|
||||
const emailInput = screen.queryByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.queryByPlaceholderText(/password/i);
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
// Fill out login form
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Wait for login to complete
|
||||
await waitFor(() => {
|
||||
// After successful login, should redirect or show authenticated content
|
||||
expect(localStorage.getItem('token')).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
it('should show error with invalid credentials', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.queryByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.queryByPlaceholderText(/password/i);
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Wait for error handling
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protected Routes', () => {
|
||||
it('should redirect unauthenticated users from protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Unauthenticated users should not have access to certain features
|
||||
// This would depend on your routing configuration
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logout Flow', () => {
|
||||
it('should logout user and clear authentication state', async () => {
|
||||
// Set a mock token
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Look for logout button/link
|
||||
const logoutButtons = screen.queryAllByText(/logout/i);
|
||||
|
||||
if (logoutButtons.length > 0) {
|
||||
fireEvent.click(logoutButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Persistence', () => {
|
||||
it('should load user from token on app mount', async () => {
|
||||
// Set a valid token
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
// Should attempt to load user
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid token on app mount', async () => {
|
||||
// Set an invalid token
|
||||
localStorage.setItem('token', 'invalid-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Token should be cleared if invalid
|
||||
// This depends on the error handling in AuthContext
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should maintain authentication across page navigation', async () => {
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Token should persist
|
||||
expect(localStorage.getItem('token')).toBe('mock-jwt-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<h2 className="alert-heading">Something went wrong</h2>
|
||||
<p>
|
||||
We're sorry, but something unexpected happened. Please try
|
||||
refreshing the page.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-3" style={{ whiteSpace: "pre-wrap" }}>
|
||||
<summary>Error details (development only)</summary>
|
||||
<p className="mt-2">
|
||||
<strong>Error:</strong> {this.state.error.toString()}
|
||||
</p>
|
||||
{this.state.errorInfo && (
|
||||
<p>
|
||||
<strong>Stack trace:</strong>
|
||||
<br />
|
||||
{this.state.errorInfo.componentStack}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
<hr />
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Events = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off, joinEvent, leaveEvent } = useContext(SocketContext);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joinedEvents, setJoinedEvents] = useState(new Set());
|
||||
|
||||
// Load events from API
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.get("/api/events");
|
||||
setEvents(res.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError("Failed to load events. Please try again later.");
|
||||
console.error("Error loading events:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// Handle real-time event updates
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleEventUpdate = (data) => {
|
||||
console.log("Received event update:", data);
|
||||
|
||||
if (data.type === "participants_updated") {
|
||||
// Update participant count for specific event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.map((event) =>
|
||||
event._id === data.eventId
|
||||
? { ...event, participants: data.participants }
|
||||
: event
|
||||
)
|
||||
);
|
||||
} else if (data.type === "new_event") {
|
||||
// Add new event to the list
|
||||
setEvents((prevEvents) => [data.event, ...prevEvents]);
|
||||
} else if (data.type === "event_updated") {
|
||||
// Update existing event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.map((event) =>
|
||||
event._id === data.event._id ? data.event : event
|
||||
)
|
||||
);
|
||||
} else if (data.type === "event_deleted") {
|
||||
// Remove deleted event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.filter((event) => event._id !== data.eventId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to event updates
|
||||
on("eventUpdate", handleEventUpdate);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("eventUpdate", handleEventUpdate);
|
||||
|
||||
// Leave all joined event rooms
|
||||
joinedEvents.forEach((eventId) => {
|
||||
leaveEvent(eventId);
|
||||
});
|
||||
};
|
||||
}, [socket, connected, on, off, joinedEvents, leaveEvent]);
|
||||
|
||||
// Join event room when viewing events
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
// Join each event room for real-time updates
|
||||
events.forEach((event) => {
|
||||
if (!joinedEvents.has(event._id)) {
|
||||
joinEvent(event._id);
|
||||
setJoinedEvents((prev) => new Set([...prev, event._id]));
|
||||
}
|
||||
});
|
||||
}, [events, socket, connected, joinEvent, joinedEvents]);
|
||||
|
||||
const rsvp = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to RSVP for events");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/events/rsvp/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setEvents(
|
||||
events.map((event) =>
|
||||
event._id === id ? { ...event, participants: res.data } : event
|
||||
)
|
||||
);
|
||||
toast.success("Successfully RSVP'd to event!");
|
||||
} catch (err) {
|
||||
console.error("Error RSVPing to event:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to RSVP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading events...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
{error}
|
||||
<button className="btn btn-primary ml-3" onClick={loadEvents}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Events</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No events available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{events.map((event) => {
|
||||
const eventDate = new Date(event.date);
|
||||
const isUpcoming = eventDate > new Date();
|
||||
|
||||
return (
|
||||
<div key={event._id} className="col-md-6 mb-4">
|
||||
<div className="card h-100">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{event.title}</h5>
|
||||
<p className="card-text">{event.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Date:</strong> {eventDate.toLocaleDateString()}{" "}
|
||||
{eventDate.toLocaleTimeString()}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Location:</strong> {event.location}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{event.organizer && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Organizer:</strong>{" "}
|
||||
{event.organizer.name || event.organizer}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<span
|
||||
className={`badge badge-${
|
||||
isUpcoming ? "success" : "secondary"
|
||||
} mr-2`}
|
||||
>
|
||||
{isUpcoming ? "Upcoming" : "Past"}
|
||||
</span>
|
||||
<span className="badge badge-info">
|
||||
{event.participants?.length || 0} Participants
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isUpcoming && auth.isAuthenticated && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm mt-3 btn-block"
|
||||
onClick={() => rsvp(event._id)}
|
||||
>
|
||||
RSVP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Login = () => {
|
||||
const { auth, login } = useContext(AuthContext);
|
||||
const [formData, setFormData] = useState({ email: "", password: "" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { email, password } = formData;
|
||||
|
||||
const onChange = (e) =>
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (error) {
|
||||
console.error("Login submission error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="spinner-border mt-5" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6">
|
||||
<h1 className="text-center">Login</h1>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={onChange}
|
||||
placeholder="Email"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={onChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Logging in...
|
||||
</>
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
// Fix for default marker icons in react-leaflet
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
|
||||
iconUrl: require("leaflet/dist/images/marker-icon.png"),
|
||||
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
|
||||
});
|
||||
|
||||
// Custom marker icons for different street statuses
|
||||
const createCustomIcon = (color) => {
|
||||
return new L.Icon({
|
||||
iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${color}.png`,
|
||||
shadowUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
};
|
||||
|
||||
const availableIcon = createCustomIcon("green");
|
||||
const adoptedIcon = createCustomIcon("blue");
|
||||
const myStreetIcon = createCustomIcon("red");
|
||||
|
||||
// Component to handle map centering on user location
|
||||
const LocationMarker = ({ userLocation, setUserLocation }) => {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userLocation) {
|
||||
map
|
||||
.locate()
|
||||
.on("locationfound", (e) => {
|
||||
setUserLocation(e.latlng);
|
||||
map.flyTo(e.latlng, 13);
|
||||
})
|
||||
.on("locationerror", (e) => {
|
||||
console.warn("Location access denied:", e.message);
|
||||
});
|
||||
}
|
||||
}, [map, userLocation, setUserLocation]);
|
||||
|
||||
return userLocation === null ? null : (
|
||||
<Marker position={userLocation}>
|
||||
<Popup>You are here</Popup>
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
|
||||
const MapView = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [streets, setStreets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [userLocation, setUserLocation] = useState(null);
|
||||
const [selectedStreet, setSelectedStreet] = useState(null);
|
||||
const [adoptingStreetId, setAdoptingStreetId] = useState(null);
|
||||
const mapRef = useRef();
|
||||
|
||||
// Default center (can be changed to your city's coordinates)
|
||||
const defaultCenter = [40.7128, -74.006]; // New York City
|
||||
|
||||
useEffect(() => {
|
||||
loadStreets();
|
||||
}, []);
|
||||
|
||||
const loadStreets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/streets");
|
||||
setStreets(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading streets:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load streets. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adoptStreet = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to adopt a street");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAdoptingStreetId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/streets/adopt/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setStreets(
|
||||
streets.map((street) => (street._id === id ? res.data : street))
|
||||
);
|
||||
setSelectedStreet(null);
|
||||
toast.success("Street adopted successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error adopting street:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to adopt street. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setAdoptingStreetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getMarkerIcon = (street) => {
|
||||
if (
|
||||
street.adoptedBy &&
|
||||
auth.user &&
|
||||
street.adoptedBy._id === auth.user._id
|
||||
) {
|
||||
return myStreetIcon;
|
||||
}
|
||||
if (street.status === "available") {
|
||||
return availableIcon;
|
||||
}
|
||||
return adoptedIcon;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading map data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && streets.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Map</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadStreets}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3">Map View</h1>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png"
|
||||
alt="Available"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">Available</small>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png"
|
||||
alt="Adopted"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">Adopted</small>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png"
|
||||
alt="My Street"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">My Streets</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: "500px", width: "100%", marginBottom: "20px" }}>
|
||||
<MapContainer
|
||||
center={defaultCenter}
|
||||
zoom={13}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<LocationMarker
|
||||
userLocation={userLocation}
|
||||
setUserLocation={setUserLocation}
|
||||
/>
|
||||
|
||||
<MarkerClusterGroup>
|
||||
{streets.map((street) => {
|
||||
// Use street coordinates or generate random coordinates for demo
|
||||
const position = street.coordinates
|
||||
? [street.coordinates.lat, street.coordinates.lng]
|
||||
: [
|
||||
defaultCenter[0] + (Math.random() - 0.5) * 0.1,
|
||||
defaultCenter[1] + (Math.random() - 0.5) * 0.1,
|
||||
];
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={street._id}
|
||||
position={position}
|
||||
icon={getMarkerIcon(street)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
setSelectedStreet(street);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<h5>{street.name}</h5>
|
||||
<p>
|
||||
<strong>Status:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available"
|
||||
? "success"
|
||||
: "primary"
|
||||
}`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
</p>
|
||||
{street.adoptedBy && (
|
||||
<p>
|
||||
<strong>Adopted by:</strong> {street.adoptedBy.name}
|
||||
</p>
|
||||
)}
|
||||
{street.description && (
|
||||
<p>
|
||||
<strong>Description:</strong> {street.description}
|
||||
</p>
|
||||
)}
|
||||
{street.status === "available" && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => adoptStreet(street._id)}
|
||||
disabled={adoptingStreetId === street._id}
|
||||
>
|
||||
{adoptingStreetId === street._id
|
||||
? "Adopting..."
|
||||
: "Adopt This Street"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{selectedStreet && (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{selectedStreet.name}</h5>
|
||||
<p className="card-text">
|
||||
<strong>Status:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
selectedStreet.status === "available" ? "success" : "primary"
|
||||
}`}
|
||||
>
|
||||
{selectedStreet.status}
|
||||
</span>
|
||||
</p>
|
||||
{selectedStreet.adoptedBy && (
|
||||
<p className="card-text">
|
||||
<strong>Adopted by:</strong> {selectedStreet.adoptedBy.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedStreet.description && (
|
||||
<p className="card-text">
|
||||
<strong>Description:</strong> {selectedStreet.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedStreet.status === "available" && (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => adoptStreet(selectedStreet._id)}
|
||||
disabled={adoptingStreetId === selectedStreet._id}
|
||||
>
|
||||
{adoptingStreetId === selectedStreet._id
|
||||
? "Adopting..."
|
||||
: "Adopt This Street"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary ml-2"
|
||||
onClick={() => setSelectedStreet(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<h3>Street List</h3>
|
||||
{streets.length === 0 ? (
|
||||
<p className="text-muted">No streets available at the moment.</p>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{streets.map((street) => (
|
||||
<li key={street._id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{street.name}</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available" ? "success" : "primary"
|
||||
}`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
{street.adoptedBy && (
|
||||
<small className="text-muted ml-2">
|
||||
by {street.adoptedBy.name}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{street.status === "available" && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => adoptStreet(street._id)}
|
||||
disabled={adoptingStreetId === street._id}
|
||||
>
|
||||
{adoptingStreetId === street._id ? "Adopting..." : "Adopt"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AuthContext } from '../context/AuthContext';
|
||||
|
||||
const Navbar = () => {
|
||||
const { auth, logout } = useContext(AuthContext);
|
||||
|
||||
const authLinks = (
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/map">Map</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/tasks">Tasks</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/feed">Feed</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/events">Events</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/rewards">Rewards</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/profile">Profile</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/premium">Premium</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a onClick={logout} href="#!" className="nav-link">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
||||
const guestLinks = (
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/register">Register</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/login">Login</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand-sm navbar-dark bg-dark mb-4">
|
||||
<div className="container">
|
||||
<Link className="navbar-brand" to="/">Adopt-a-Street</Link>
|
||||
<div className="collapse navbar-collapse">
|
||||
{auth.isAuthenticated ? authLinks : guestLinks}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Premium component displays premium subscription information and allows users to subscribe
|
||||
*/
|
||||
const Premium = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
|
||||
// Subscribe to premium
|
||||
const subscribe = async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to subscribe to premium");
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.user?.isPremium) {
|
||||
toast.info("You are already a premium member!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubscribing(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.post(
|
||||
"/api/payments/subscribe",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
toast.success(
|
||||
res.data.message || "Subscription successful! Welcome to premium!"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error subscribing:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to subscribe. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Premium Subscription</h1>
|
||||
|
||||
{auth.user?.isPremium ? (
|
||||
<div className="alert alert-success mb-4">
|
||||
<h4 className="alert-heading">You are a Premium Member!</h4>
|
||||
<p>
|
||||
Thank you for your support! You have access to all premium features.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-info mb-4">
|
||||
<h4 className="alert-heading">Upgrade to Premium</h4>
|
||||
<p>
|
||||
Unlock exclusive rewards and features by subscribing to our premium
|
||||
plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8 offset-md-2">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-center mb-4">Premium Benefits</h3>
|
||||
|
||||
<ul className="list-group list-group-flush mb-4">
|
||||
<li className="list-group-item">
|
||||
<strong>Exclusive Rewards:</strong> Access to premium-only
|
||||
rewards in the rewards catalog
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Priority Support:</strong> Get faster responses from
|
||||
our support team
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Advanced Features:</strong> Unlock advanced analytics
|
||||
and reporting tools
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Special Badge:</strong> Display your premium status
|
||||
with a special badge
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Early Access:</strong> Be the first to try new
|
||||
features and updates
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Ad-Free Experience:</strong> Enjoy the platform
|
||||
without any advertisements
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="text-primary">$9.99/month</h2>
|
||||
<p className="text-muted">Cancel anytime, no commitments</p>
|
||||
</div>
|
||||
|
||||
{!auth.isAuthenticated ? (
|
||||
<div className="alert alert-warning text-center">
|
||||
<p className="mb-2">
|
||||
Please log in to subscribe to premium.
|
||||
</p>
|
||||
<a href="/login" className="btn btn-primary">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
) : auth.user?.isPremium ? (
|
||||
<div className="text-center">
|
||||
<button className="btn btn-success btn-lg" disabled>
|
||||
Already Subscribed
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
onClick={subscribe}
|
||||
disabled={subscribing}
|
||||
>
|
||||
{subscribing ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Subscribe Now"
|
||||
)}
|
||||
</button>
|
||||
<p className="text-muted mt-3">
|
||||
<small>
|
||||
Note: This is a mock subscription for development
|
||||
purposes. No actual payment will be processed.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt-5">
|
||||
<div className="col-md-12">
|
||||
<h3 className="text-center mb-4">Frequently Asked Questions</h3>
|
||||
|
||||
<div className="accordion" id="faqAccordion">
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq1">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse1"
|
||||
aria-expanded="true"
|
||||
aria-controls="collapse1"
|
||||
>
|
||||
Can I cancel my subscription anytime?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse1"
|
||||
className="collapse"
|
||||
aria-labelledby="faq1"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
Yes! You can cancel your premium subscription at any time.
|
||||
Your premium benefits will remain active until the end of your
|
||||
current billing period.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq2">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link collapsed"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse2"
|
||||
aria-expanded="false"
|
||||
aria-controls="collapse2"
|
||||
>
|
||||
What payment methods do you accept?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse2"
|
||||
className="collapse"
|
||||
aria-labelledby="faq2"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
We accept all major credit cards (Visa, MasterCard, American
|
||||
Express) and PayPal. All payments are processed securely
|
||||
through Stripe.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq3">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link collapsed"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse3"
|
||||
aria-expanded="false"
|
||||
aria-controls="collapse3"
|
||||
>
|
||||
Will my points carry over if I upgrade?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse3"
|
||||
className="collapse"
|
||||
aria-labelledby="faq3"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
Absolutely! All your existing points, badges, and adopted
|
||||
streets will remain unchanged when you upgrade to premium.
|
||||
You'll simply gain access to additional premium features and
|
||||
rewards.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Premium;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Profile component displays user profile information, adopted streets, and badges
|
||||
*/
|
||||
const Profile = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load user profile from API
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!auth.user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const userId = auth.user.id || auth.user._id;
|
||||
const res = await axios.get(`/api/users/${userId}`);
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading profile:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load profile. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Profile</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadProfile}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return (
|
||||
<div className="alert alert-warning m-3" role="alert">
|
||||
<h4 className="alert-heading">Not Logged In</h4>
|
||||
<p>Please log in to view your profile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="alert alert-info m-3" role="alert">
|
||||
<h4 className="alert-heading">No Profile Data</h4>
|
||||
<p>Unable to load profile information.</p>
|
||||
<button className="btn btn-primary" onClick={loadProfile}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}'s Profile</h1>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Profile Information</h5>
|
||||
<p className="card-text">
|
||||
<strong>Email:</strong> {user.email}
|
||||
</p>
|
||||
<p className="card-text">
|
||||
<strong>Points:</strong>{" "}
|
||||
<span className="badge badge-primary">{user.points || 0}</span>
|
||||
</p>
|
||||
{user.isPremium && (
|
||||
<p className="card-text">
|
||||
<span className="badge badge-warning">Premium Member</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Adopted Streets</h5>
|
||||
{user.adoptedStreets && user.adoptedStreets.length > 0 ? (
|
||||
<ul className="list-group">
|
||||
{user.adoptedStreets.map((street) => (
|
||||
<li key={street._id || street} className="list-group-item">
|
||||
{street.name || street}
|
||||
{street.status && (
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available" ? "success" : "primary"
|
||||
} ml-2`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">
|
||||
You haven't adopted any streets yet. Visit the map to adopt a
|
||||
street!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Badges</h5>
|
||||
{user.badges && user.badges.length > 0 ? (
|
||||
<ul className="list-group">
|
||||
{user.badges.map((badge, index) => (
|
||||
<li key={index} className="list-group-item">
|
||||
<span className="badge badge-success mr-2">
|
||||
{badge}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">
|
||||
No badges earned yet. Complete tasks and participate in events to
|
||||
earn badges!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.tasksCompleted !== undefined && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Statistics</h5>
|
||||
<p className="card-text">
|
||||
<strong>Tasks Completed:</strong>{" "}
|
||||
<span className="badge badge-info">{user.tasksCompleted}</span>
|
||||
</p>
|
||||
{user.eventsAttended !== undefined && (
|
||||
<p className="card-text">
|
||||
<strong>Events Attended:</strong>{" "}
|
||||
<span className="badge badge-info">{user.eventsAttended}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Register = () => {
|
||||
const { auth, register } = useContext(AuthContext);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { name, email, password } = formData;
|
||||
|
||||
const onChange = (e) =>
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(name, email, password);
|
||||
} catch (error) {
|
||||
console.error("Registration submission error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="spinner-border mt-5" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6">
|
||||
<h1 className="text-center">Register</h1>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="form-control"
|
||||
value={name}
|
||||
onChange={onChange}
|
||||
placeholder="Name"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={onChange}
|
||||
placeholder="Email"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={onChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Rewards component displays available rewards and allows users to redeem them
|
||||
*/
|
||||
const Rewards = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [rewards, setRewards] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [redeemingRewardId, setRedeemingRewardId] = useState(null);
|
||||
|
||||
// Load rewards from API
|
||||
const loadRewards = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/rewards");
|
||||
setRewards(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading rewards:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load rewards. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRewards();
|
||||
}, [loadRewards]);
|
||||
|
||||
// Redeem a reward
|
||||
const redeemReward = async (id, rewardName, cost) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to redeem rewards");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRedeemingRewardId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
await axios.post(
|
||||
`/api/rewards/redeem/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
toast.success(
|
||||
`Reward "${rewardName}" redeemed successfully! ${cost} points deducted.`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error redeeming reward:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to redeem reward. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setRedeemingRewardId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading rewards...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Rewards</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadRewards}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Rewards</h1>
|
||||
|
||||
{auth.user && (
|
||||
<div className="alert alert-info mb-4">
|
||||
<strong>Your Points:</strong>{" "}
|
||||
<span className="badge badge-primary">{auth.user.points || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!auth.isAuthenticated && (
|
||||
<div className="alert alert-warning mb-4">
|
||||
Please log in to view and redeem rewards.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rewards.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No rewards available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{rewards.map((reward) => {
|
||||
const canAfford =
|
||||
auth.isAuthenticated &&
|
||||
auth.user &&
|
||||
(auth.user.points || 0) >= reward.cost;
|
||||
const canRedeem =
|
||||
canAfford &&
|
||||
(!reward.isPremium ||
|
||||
(reward.isPremium && auth.user.isPremium));
|
||||
|
||||
return (
|
||||
<div key={reward._id} className="col-md-6 col-lg-4 mb-4">
|
||||
<div className="card h-100">
|
||||
<div className="card-body d-flex flex-column">
|
||||
<h5 className="card-title">{reward.name}</h5>
|
||||
<p className="card-text flex-grow-1">
|
||||
{reward.description}
|
||||
</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<strong>Cost:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
canAfford ? "success" : "warning"
|
||||
}`}
|
||||
>
|
||||
{reward.cost} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{reward.isPremium && (
|
||||
<div className="mb-2">
|
||||
<span className="badge badge-warning">
|
||||
Premium Only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reward.quantity !== undefined && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
Available: {reward.quantity}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auth.isAuthenticated ? (
|
||||
<button
|
||||
className={`btn btn-${
|
||||
canRedeem ? "primary" : "secondary"
|
||||
} btn-block mt-2`}
|
||||
onClick={() =>
|
||||
redeemReward(reward._id, reward.name, reward.cost)
|
||||
}
|
||||
disabled={
|
||||
!canRedeem ||
|
||||
redeemingRewardId === reward._id ||
|
||||
(reward.quantity !== undefined && reward.quantity <= 0)
|
||||
}
|
||||
>
|
||||
{redeemingRewardId === reward._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Redeeming...
|
||||
</>
|
||||
) : !canAfford ? (
|
||||
"Insufficient Points"
|
||||
) : reward.isPremium && !auth.user.isPremium ? (
|
||||
"Premium Required"
|
||||
) : reward.quantity !== undefined &&
|
||||
reward.quantity <= 0 ? (
|
||||
"Out of Stock"
|
||||
) : (
|
||||
"Redeem"
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-block mt-2" disabled>
|
||||
Login to Redeem
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rewards;
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
|
||||
/**
|
||||
* SocialFeed component displays community posts and allows creating new posts
|
||||
* Includes real-time updates via Socket.IO
|
||||
*/
|
||||
const SocialFeed = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [likingPostId, setLikingPostId] = useState(null);
|
||||
|
||||
// Load posts from API
|
||||
const loadPosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/posts");
|
||||
setPosts(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading posts:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load posts. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
// Handle real-time post updates via Socket.IO
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleNewPost = (data) => {
|
||||
console.log("Received new post:", data);
|
||||
setPosts((prevPosts) => [data.post, ...prevPosts]);
|
||||
toast.info("New post added to feed!");
|
||||
};
|
||||
|
||||
const handlePostUpdate = (data) => {
|
||||
console.log("Received post update:", data);
|
||||
|
||||
if (data.type === "post_liked") {
|
||||
// Update specific post likes
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.postId ? { ...post, likes: data.likes } : post
|
||||
)
|
||||
);
|
||||
} else if (data.type === "post_updated") {
|
||||
// Update existing post
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.post._id ? data.post : post
|
||||
)
|
||||
);
|
||||
} else if (data.type === "post_deleted") {
|
||||
// Remove deleted post
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.filter((post) => post._id !== data.postId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewComment = (data) => {
|
||||
console.log("Received new comment:", data);
|
||||
// Update post with new comment
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.postId
|
||||
? { ...post, comments: [...(post.comments || []), data.comment] }
|
||||
: post
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Subscribe to post events
|
||||
on("newPost", handleNewPost);
|
||||
on("postUpdate", handlePostUpdate);
|
||||
on("newComment", handleNewComment);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("newPost", handleNewPost);
|
||||
off("postUpdate", handlePostUpdate);
|
||||
off("newComment", handleNewComment);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
|
||||
// Like a post
|
||||
const likePost = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to like posts");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLikingPostId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/posts/like/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setPosts(
|
||||
posts.map((post) =>
|
||||
post._id === id ? { ...post, likes: res.data } : post
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error liking post:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to like post. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLikingPostId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Submit a new post
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to create posts");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
toast.warning("Post content cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.post(
|
||||
"/api/posts",
|
||||
{ content },
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setPosts([res.data, ...posts]);
|
||||
setContent("");
|
||||
toast.success("Post created successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error creating post:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to create post. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading posts...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Posts</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadPosts}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Social Feed</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Create a Post</h5>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="What's on your mind?"
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || !content.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
"Post"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No posts yet. Be the first to share something!
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{posts.map((post) => (
|
||||
<li key={post._id} className="list-group-item">
|
||||
<div className="mb-2">
|
||||
<p className="mb-2">{post.content}</p>
|
||||
<small className="text-muted">
|
||||
By: <strong>{post.user?.name || "Unknown User"}</strong>
|
||||
{post.createdAt && (
|
||||
<span className="ml-2">
|
||||
{new Date(post.createdAt).toLocaleDateString()}{" "}
|
||||
{new Date(post.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => likePost(post._id)}
|
||||
disabled={!auth.isAuthenticated || likingPostId === post._id}
|
||||
>
|
||||
{likingPostId === post._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Liking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Like ({post.likes?.length || 0})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{post.comments && post.comments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<small className="text-muted">
|
||||
{post.comments.length} comment{post.comments.length !== 1 ? "s" : ""}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialFeed;
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
|
||||
/**
|
||||
* TaskList component displays maintenance tasks and allows task completion
|
||||
* Includes real-time updates via Socket.IO
|
||||
*/
|
||||
const TaskList = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [completingTaskId, setCompletingTaskId] = useState(null);
|
||||
|
||||
// Load tasks from API
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/tasks");
|
||||
setTasks(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading tasks:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load tasks. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// Handle real-time task updates via Socket.IO
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleTaskUpdate = (data) => {
|
||||
console.log("Received task update:", data);
|
||||
|
||||
if (data.type === "task_completed") {
|
||||
// Update specific task status
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task._id === data.taskId ? { ...task, status: "completed" } : task
|
||||
)
|
||||
);
|
||||
toast.success("Task completed!");
|
||||
} else if (data.type === "new_task") {
|
||||
// Add new task to the list
|
||||
setTasks((prevTasks) => [data.task, ...prevTasks]);
|
||||
toast.info("New task available!");
|
||||
} else if (data.type === "task_updated") {
|
||||
// Update existing task
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task._id === data.task._id ? data.task : task
|
||||
)
|
||||
);
|
||||
} else if (data.type === "task_deleted") {
|
||||
// Remove deleted task
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.filter((task) => task._id !== data.taskId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to task updates
|
||||
on("taskUpdate", handleTaskUpdate);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("taskUpdate", handleTaskUpdate);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
|
||||
// Complete a task
|
||||
const completeTask = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to complete tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCompletingTaskId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/tasks/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setTasks(tasks.map((task) => (task._id === id ? res.data : task)));
|
||||
toast.success("Task completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error completing task:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to complete task. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setCompletingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Tasks</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadTasks}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Task List</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No tasks available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task._id}
|
||||
className="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div>
|
||||
<strong>{task.description}</strong>
|
||||
<br />
|
||||
<span
|
||||
className={`badge badge-${
|
||||
task.status === "pending" ? "warning" : "success"
|
||||
} mr-2`}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.street && (
|
||||
<small className="text-muted">Street: {task.street.name || task.street}</small>
|
||||
)}
|
||||
{task.assignedTo && (
|
||||
<small className="text-muted ml-2">
|
||||
Assigned to: {task.assignedTo.name || task.assignedTo}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{task.status === "pending" && auth.isAuthenticated && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => completeTask(task._id)}
|
||||
disabled={completingTaskId === task._id}
|
||||
>
|
||||
{completingTaskId === task._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Completing...
|
||||
</>
|
||||
) : (
|
||||
"Complete"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
|
||||
// Component that throws an error
|
||||
const ThrowError = ({ shouldThrow }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return <div>No Error</div>;
|
||||
};
|
||||
|
||||
describe('ErrorBoundary Component', () => {
|
||||
beforeEach(() => {
|
||||
// Suppress console errors during error boundary tests
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error.mockRestore();
|
||||
});
|
||||
|
||||
describe('Normal Rendering', () => {
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Child Component</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Child Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Child 1</div>
|
||||
<div>Child 2</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should catch errors and display fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error message in fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/please try refreshing/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render children when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('No Error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should catch errors in nested components', () => {
|
||||
const NestedComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NestedComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh Button', () => {
|
||||
it('should display a refresh button in error state', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reload page when refresh button is clicked', () => {
|
||||
// Mock window.location.reload
|
||||
delete window.location;
|
||||
window.location = { reload: jest.fn() };
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
|
||||
refreshButton.click();
|
||||
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Updates', () => {
|
||||
it('should update state when error is caught', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Error')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Info', () => {
|
||||
it('should not crash when rendering error boundary', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should log error to console', () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error');
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Styling', () => {
|
||||
it('should render error container with proper classes', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const container = screen.getByRole('heading', { name: /something went wrong/i }).closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Login from '../Login';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
|
||||
// Mock useNavigate
|
||||
const mockedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Navigate: ({ to }) => {
|
||||
mockedNavigate(to);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Login Component', () => {
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
const mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
const renderLogin = (contextValue = mockAuthContext) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
<Login />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogin.mockClear();
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render login form', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render email input field', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(emailInput).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should render password input field', () => {
|
||||
renderLogin();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should update email field on change', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
});
|
||||
|
||||
it('should update password field on change', () => {
|
||||
renderLogin();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('should have required fields', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
|
||||
expect(emailInput).toBeRequired();
|
||||
expect(passwordInput).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call login function on form submit', async () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable form fields during submission', async () => {
|
||||
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(passwordInput).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/logging in/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle login errors gracefully', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockLogin.mockRejectedValue(new Error('Login failed'));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrong' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should redirect to /map when already authenticated', () => {
|
||||
const authenticatedContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: { name: 'Test User' },
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(authenticatedContext);
|
||||
|
||||
expect(mockedNavigate).toHaveBeenCalledWith('/map');
|
||||
});
|
||||
|
||||
it('should show loading spinner when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(loadingContext);
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show form when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(loadingContext);
|
||||
|
||||
expect(screen.queryByPlaceholderText(/email/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/password/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible form elements', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
|
||||
expect(emailInput).toHaveAttribute('name', 'email');
|
||||
expect(passwordInput).toHaveAttribute('name', 'password');
|
||||
});
|
||||
|
||||
it('should have accessible button', () => {
|
||||
renderLogin();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty Form Submission', () => {
|
||||
it('should not submit with empty fields', () => {
|
||||
renderLogin();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(mockLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Register from '../Register';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Navigate: ({ to }) => {
|
||||
mockedNavigate(to);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Register Component', () => {
|
||||
const mockRegister = jest.fn();
|
||||
|
||||
const mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
user: null,
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
const renderRegister = (contextValue = mockAuthContext) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
<Register />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRegister.mockClear();
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render registration form', () => {
|
||||
renderRegister();
|
||||
|
||||
expect(screen.getByRole('heading', { name: /register/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/^password$/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/confirm password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all required input fields', () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
expect(nameInput).toBeRequired();
|
||||
expect(emailInput).toBeRequired();
|
||||
expect(passwordInput).toBeRequired();
|
||||
expect(confirmPasswordInput).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Input Changes', () => {
|
||||
it('should update name field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
|
||||
expect(nameInput).toHaveValue('John Doe');
|
||||
});
|
||||
|
||||
it('should update email field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
|
||||
expect(emailInput).toHaveValue('john@example.com');
|
||||
});
|
||||
|
||||
it('should update password field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('should update confirm password field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(confirmPasswordInput).toHaveValue('password123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call register function with valid data', async () => {
|
||||
mockRegister.mockResolvedValue({ success: true });
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith('John Doe', 'john@example.com', 'password123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable form during submission', async () => {
|
||||
mockRegister.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nameInput).toBeDisabled();
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(passwordInput).toBeDisabled();
|
||||
expect(confirmPasswordInput).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Validation', () => {
|
||||
it('should validate minimum password length', async () => {
|
||||
renderRegister();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '12345' } });
|
||||
|
||||
// Password should have minLength attribute
|
||||
expect(passwordInput).toHaveAttribute('minLength');
|
||||
});
|
||||
|
||||
it('should show error when passwords do not match', async () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'different' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Should not call register if passwords don't match
|
||||
expect(mockRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should redirect to /map when already authenticated', () => {
|
||||
const authenticatedContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: { name: 'Test User' },
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
renderRegister(authenticatedContext);
|
||||
|
||||
expect(mockedNavigate).toHaveBeenCalledWith('/map');
|
||||
});
|
||||
|
||||
it('should show loading spinner when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
renderRegister(loadingContext);
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field Types', () => {
|
||||
it('should have correct input types', () => {
|
||||
renderRegister();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle registration errors', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockRegister.mockRejectedValue(new Error('Registration failed'));
|
||||
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const [auth, setAuth] = useState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
axios.defaults.headers.common["x-auth-token"] = token;
|
||||
try {
|
||||
const res = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
user: res.data,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load user:", error);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const res = await axios.post("/api/auth/login", { email, password });
|
||||
localStorage.setItem("token", res.data.token);
|
||||
axios.defaults.headers.common["x-auth-token"] = res.data.token;
|
||||
|
||||
try {
|
||||
const userRes = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token: res.data.token,
|
||||
isAuthenticated: true,
|
||||
user: userRes.data,
|
||||
loading: false,
|
||||
});
|
||||
toast.success("Login successful!");
|
||||
return { success: true };
|
||||
} catch (userError) {
|
||||
console.error("Failed to fetch user after login:", userError);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
toast.error("Login succeeded but failed to load user data");
|
||||
return { success: false, error: "Failed to load user data" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (name, email, password) => {
|
||||
try {
|
||||
const res = await axios.post("/api/auth/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
localStorage.setItem("token", res.data.token);
|
||||
axios.defaults.headers.common["x-auth-token"] = res.data.token;
|
||||
|
||||
try {
|
||||
const userRes = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token: res.data.token,
|
||||
isAuthenticated: true,
|
||||
user: userRes.data,
|
||||
loading: false,
|
||||
});
|
||||
toast.success("Registration successful! Welcome aboard!");
|
||||
return { success: true };
|
||||
} catch (userError) {
|
||||
console.error("Failed to fetch user after registration:", userError);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
toast.error("Registration succeeded but failed to load user data");
|
||||
return { success: false, error: "Failed to load user data" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
"Registration failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
toast.info("You have been logged out");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ auth, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
/**
|
||||
* SocketProvider manages WebSocket connections and real-time event handling
|
||||
* Automatically reconnects on disconnection and provides event subscription methods
|
||||
*/
|
||||
const SocketProvider = ({ children }) => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize socket connection
|
||||
const socketInstance = io("http://localhost:5000", {
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
socketInstance.on("connect", () => {
|
||||
console.log("Socket.IO connected:", socketInstance.id);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("disconnect", (reason) => {
|
||||
console.log("Socket.IO disconnected:", reason);
|
||||
setConnected(false);
|
||||
|
||||
// Automatically reconnect if disconnection was unexpected
|
||||
if (reason === "io server disconnect") {
|
||||
// Server initiated disconnect, reconnect manually
|
||||
socketInstance.connect();
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on("connect_error", (error) => {
|
||||
console.error("Socket.IO connection error:", error);
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnected after", attemptNumber, "attempts");
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_attempt", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnection attempt", attemptNumber);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_error", (error) => {
|
||||
console.error("Socket.IO reconnection error:", error);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_failed", () => {
|
||||
console.error("Socket.IO reconnection failed");
|
||||
});
|
||||
|
||||
// Generic notification handler
|
||||
socketInstance.on("notification", (data) => {
|
||||
console.log("Received notification:", data);
|
||||
setNotifications((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
...data,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Connect socket if user is authenticated
|
||||
if (auth.isAuthenticated) {
|
||||
socketInstance.connect();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
socketInstance.removeAllListeners();
|
||||
};
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Join a specific event room
|
||||
const joinEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Joining event room:", eventId);
|
||||
socket.emit("joinEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Leave a specific event room
|
||||
const leaveEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Leaving event room:", eventId);
|
||||
socket.emit("leaveEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Subscribe to a specific event
|
||||
const on = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.on(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Unsubscribe from a specific event
|
||||
const off = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.off(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Emit an event
|
||||
const emit = useCallback(
|
||||
(event, data) => {
|
||||
if (socket && connected) {
|
||||
socket.emit(event, data);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Clear a notification
|
||||
const clearNotification = useCallback((notificationId) => {
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notification) => notification.id !== notificationId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clear all notifications
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
socket,
|
||||
connected,
|
||||
notifications,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
clearNotification,
|
||||
clearAllNotifications,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocketProvider;
|
||||
|
||||
/**
|
||||
* Custom hook to use socket context
|
||||
* @returns {Object} Socket context value
|
||||
*/
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// Mock API base URL (matches the proxy in package.json)
|
||||
const API_URL = 'http://localhost:5000';
|
||||
|
||||
// Mock data
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
isPremium: false,
|
||||
points: 100,
|
||||
adoptedStreets: [],
|
||||
completedTasks: [],
|
||||
posts: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const mockStreets = [
|
||||
{
|
||||
_id: 'street1',
|
||||
name: 'Main Street',
|
||||
city: 'Test City',
|
||||
state: 'TS',
|
||||
location: {
|
||||
type: 'Point',
|
||||
coordinates: [-73.935242, 40.730610],
|
||||
},
|
||||
adoptedBy: 'user123',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
_id: 'street2',
|
||||
name: 'Oak Avenue',
|
||||
city: 'Test City',
|
||||
state: 'TS',
|
||||
location: {
|
||||
type: 'Point',
|
||||
coordinates: [-73.945242, 40.740610],
|
||||
},
|
||||
adoptedBy: 'user456',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTasks = [
|
||||
{
|
||||
_id: 'task1',
|
||||
street: 'street1',
|
||||
description: 'Clean up litter',
|
||||
type: 'cleaning',
|
||||
status: 'pending',
|
||||
createdBy: 'user123',
|
||||
},
|
||||
{
|
||||
_id: 'task2',
|
||||
street: 'street1',
|
||||
description: 'Fix pothole',
|
||||
type: 'repair',
|
||||
status: 'completed',
|
||||
createdBy: 'user123',
|
||||
},
|
||||
];
|
||||
|
||||
const mockPosts = [
|
||||
{
|
||||
_id: 'post1',
|
||||
user: {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
profilePicture: null,
|
||||
},
|
||||
content: 'Just cleaned up Main Street!',
|
||||
type: 'text',
|
||||
likes: [],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
_id: 'post2',
|
||||
user: {
|
||||
_id: 'user456',
|
||||
name: 'Another User',
|
||||
profilePicture: null,
|
||||
},
|
||||
content: 'Great work everyone!',
|
||||
type: 'text',
|
||||
likes: ['user123'],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
_id: 'event1',
|
||||
title: 'Community Cleanup',
|
||||
description: 'Annual community cleanup event',
|
||||
date: new Date(Date.now() + 86400000).toISOString(),
|
||||
location: 'Central Park',
|
||||
organizer: 'user123',
|
||||
participants: [],
|
||||
},
|
||||
{
|
||||
_id: 'event2',
|
||||
title: 'Tree Planting Day',
|
||||
description: 'Help us plant trees in the neighborhood',
|
||||
date: new Date(Date.now() + 172800000).toISOString(),
|
||||
location: 'Riverside Park',
|
||||
organizer: 'user456',
|
||||
participants: ['user123'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRewards = [
|
||||
{
|
||||
_id: 'reward1',
|
||||
name: 'Bronze Badge',
|
||||
description: 'Complete 5 tasks',
|
||||
pointsCost: 50,
|
||||
isPremium: false,
|
||||
},
|
||||
{
|
||||
_id: 'reward2',
|
||||
name: 'Silver Badge',
|
||||
description: 'Complete 20 tasks',
|
||||
pointsCost: 150,
|
||||
isPremium: false,
|
||||
},
|
||||
{
|
||||
_id: 'reward3',
|
||||
name: 'Premium Badge',
|
||||
description: 'Exclusive premium badge',
|
||||
pointsCost: 200,
|
||||
isPremium: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Auth handlers
|
||||
const authHandlers = [
|
||||
// Register
|
||||
http.post(`${API_URL}/api/auth/register`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({
|
||||
token: 'mock-jwt-token',
|
||||
});
|
||||
}),
|
||||
|
||||
// Login
|
||||
http.post(`${API_URL}/api/auth/login`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body.email === 'test@example.com' && body.password === 'password123') {
|
||||
return HttpResponse.json({
|
||||
token: 'mock-jwt-token',
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Invalid credentials' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}),
|
||||
|
||||
// Get authenticated user
|
||||
http.get(`${API_URL}/api/auth`, ({ request }) => {
|
||||
const token = request.headers.get('x-auth-token');
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'No token, authorization denied' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(mockUser);
|
||||
}),
|
||||
];
|
||||
|
||||
// Streets handlers
|
||||
const streetsHandlers = [
|
||||
// Get all streets
|
||||
http.get(`${API_URL}/api/streets`, () => {
|
||||
return HttpResponse.json(mockStreets);
|
||||
}),
|
||||
|
||||
// Adopt a street
|
||||
http.put(`${API_URL}/api/streets/adopt/:id`, ({ params }) => {
|
||||
const street = mockStreets.find(s => s._id === params.id);
|
||||
|
||||
if (!street) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Street not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...street,
|
||||
adoptedBy: 'user123',
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Tasks handlers
|
||||
const tasksHandlers = [
|
||||
// Get all tasks
|
||||
http.get(`${API_URL}/api/tasks`, () => {
|
||||
return HttpResponse.json(mockTasks);
|
||||
}),
|
||||
|
||||
// Create a task
|
||||
http.post(`${API_URL}/api/tasks`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newTask = {
|
||||
_id: `task${Date.now()}`,
|
||||
...body,
|
||||
status: 'pending',
|
||||
createdBy: 'user123',
|
||||
};
|
||||
return HttpResponse.json(newTask);
|
||||
}),
|
||||
|
||||
// Complete a task
|
||||
http.put(`${API_URL}/api/tasks/:id/complete`, ({ params }) => {
|
||||
const task = mockTasks.find(t => t._id === params.id);
|
||||
|
||||
if (!task) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Task not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...task,
|
||||
status: 'completed',
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Posts handlers
|
||||
const postsHandlers = [
|
||||
// Get all posts
|
||||
http.get(`${API_URL}/api/posts`, () => {
|
||||
return HttpResponse.json(mockPosts);
|
||||
}),
|
||||
|
||||
// Create a post
|
||||
http.post(`${API_URL}/api/posts`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newPost = {
|
||||
_id: `post${Date.now()}`,
|
||||
user: {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
profilePicture: null,
|
||||
},
|
||||
...body,
|
||||
likes: [],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json(newPost);
|
||||
}),
|
||||
|
||||
// Like a post
|
||||
http.put(`${API_URL}/api/posts/like/:id`, ({ params }) => {
|
||||
const post = mockPosts.find(p => p._id === params.id);
|
||||
|
||||
if (!post) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Post not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...post,
|
||||
likes: [...post.likes, 'user123'],
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Events handlers
|
||||
const eventsHandlers = [
|
||||
// Get all events
|
||||
http.get(`${API_URL}/api/events`, () => {
|
||||
return HttpResponse.json(mockEvents);
|
||||
}),
|
||||
|
||||
// Create an event
|
||||
http.post(`${API_URL}/api/events`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newEvent = {
|
||||
_id: `event${Date.now()}`,
|
||||
...body,
|
||||
organizer: 'user123',
|
||||
participants: [],
|
||||
};
|
||||
return HttpResponse.json(newEvent);
|
||||
}),
|
||||
|
||||
// RSVP to an event
|
||||
http.put(`${API_URL}/api/events/rsvp/:id`, ({ params }) => {
|
||||
const event = mockEvents.find(e => e._id === params.id);
|
||||
|
||||
if (!event) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Event not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json([...event.participants, 'user123']);
|
||||
}),
|
||||
];
|
||||
|
||||
// Rewards handlers
|
||||
const rewardsHandlers = [
|
||||
// Get all rewards
|
||||
http.get(`${API_URL}/api/rewards`, () => {
|
||||
return HttpResponse.json(mockRewards);
|
||||
}),
|
||||
|
||||
// Redeem a reward
|
||||
http.post(`${API_URL}/api/rewards/redeem/:id`, ({ params, request }) => {
|
||||
const reward = mockRewards.find(r => r._id === params.id);
|
||||
|
||||
if (!reward) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Reward not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (mockUser.points < reward.pointsCost) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Not enough points' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (reward.isPremium && !mockUser.isPremium) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Premium reward not available' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({ msg: 'Reward redeemed successfully' });
|
||||
}),
|
||||
];
|
||||
|
||||
// Users handlers
|
||||
const usersHandlers = [
|
||||
// Get user profile
|
||||
http.get(`${API_URL}/api/users/:id`, ({ params }) => {
|
||||
if (params.id === 'user123') {
|
||||
return HttpResponse.json(mockUser);
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}),
|
||||
|
||||
// Update user profile
|
||||
http.put(`${API_URL}/api/users/:id`, async ({ params, request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (params.id === 'user123') {
|
||||
return HttpResponse.json({
|
||||
...mockUser,
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
// Export all handlers
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...streetsHandlers,
|
||||
...tasksHandlers,
|
||||
...postsHandlers,
|
||||
...eventsHandlers,
|
||||
...rewardsHandlers,
|
||||
...usersHandlers,
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
// Setup MSW server with default handlers
|
||||
export const server = setupServer(...handlers);
|
||||
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,62 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// MSW (Mock Service Worker) for API mocking
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// Establish API mocking before all tests
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||
|
||||
// Reset any request handlers that we may add during the tests,
|
||||
// so they don't affect other tests
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
// Clean up after the tests are finished
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock window.matchMedia (used by some components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // Deprecated
|
||||
removeListener: jest.fn(), // Deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Suppress console errors during tests (except actual errors)
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].includes('Warning: ReactDOM.render') ||
|
||||
args[0].includes('Warning: useLayoutEffect') ||
|
||||
args[0].includes('Not implemented: HTMLFormElement.prototype.submit'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
Reference in New Issue
Block a user