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:
William Valentin
2025-11-01 11:01:06 -07:00
parent 223dbb14b7
commit 2df5a303ed
38 changed files with 25312 additions and 3 deletions
+8 -2
View File
@@ -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
Submodule frontend deleted from e3a8eacec4
+2
View File
@@ -0,0 +1,2 @@
node_modules
build
+70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18250
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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

+46
View File
@@ -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

+25
View File
@@ -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"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
View File
+56
View File
@@ -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');
});
});
});
+62
View File
@@ -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;
+234
View File
@@ -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">&#9679;</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;
+101
View File
@@ -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;
+374
View File
@@ -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='&copy; <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;
+60
View File
@@ -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;
+259
View File
@@ -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;
+185
View File
@@ -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;
+117
View File
@@ -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;
+218
View File
@@ -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;
+312
View File
@@ -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">&#9679;</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;
+213
View File
@@ -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">&#9679;</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();
});
});
});
+143
View File
@@ -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;
+188
View File
@@ -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;
};
View File
+21
View File
@@ -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();
+396
View File
@@ -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,
];
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// Setup MSW server with default handlers
export const server = setupServer(...handlers);
+13
View File
@@ -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;
+62
View File
@@ -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;
});