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:
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
import AuthProvider from "./context/AuthContext";
|
||||
import SocketProvider from "./context/SocketContext";
|
||||
import Login from "./components/Login";
|
||||
import Register from "./components/Register";
|
||||
import MapView from "./components/MapView";
|
||||
import TaskList from "./components/TaskList";
|
||||
import SocialFeed from "./components/SocialFeed";
|
||||
import Profile from "./components/Profile";
|
||||
import Events from "./components/Events";
|
||||
import Rewards from "./components/Rewards";
|
||||
import Premium from "./components/Premium";
|
||||
import Navbar from "./components/Navbar";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/tasks" element={<TaskList />} />
|
||||
<Route path="/feed" element={<SocialFeed />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/rewards" element={<Rewards />} />
|
||||
<Route path="/premium" element={<Premium />} />
|
||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
</Router>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '../App';
|
||||
import AuthProvider from '../context/AuthContext';
|
||||
|
||||
// Mock react-toastify to avoid toast errors
|
||||
jest.mock('react-toastify', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
ToastContainer: () => null,
|
||||
}));
|
||||
|
||||
// Mock Leaflet map components to avoid rendering issues
|
||||
jest.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div>TileLayer</div>,
|
||||
Marker: () => <div>Marker</div>,
|
||||
Popup: ({ children }) => <div>{children}</div>,
|
||||
useMap: () => ({
|
||||
flyTo: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-leaflet-cluster', () => ({
|
||||
default: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Socket.IO
|
||||
jest.mock('socket.io-client', () => {
|
||||
return jest.fn(() => ({
|
||||
on: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
off: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Authentication Flow Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderApp = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Registration Flow', () => {
|
||||
it('should allow user to register and access protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
// Should be on the login page initially (or map if not authenticated)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate to register page if there's a link
|
||||
const registerLinks = screen.queryAllByText(/register/i);
|
||||
if (registerLinks.length > 0) {
|
||||
fireEvent.click(registerLinks[0]);
|
||||
}
|
||||
|
||||
// Fill out registration form
|
||||
await waitFor(() => {
|
||||
const nameInput = screen.queryByPlaceholderText(/name/i);
|
||||
if (nameInput) {
|
||||
fireEvent.change(nameInput, { target: { value: 'Test User' } });
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
fireEvent.click(submitButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login Flow', () => {
|
||||
it('should allow user to login and access protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
// Wait for initial loading
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Look for login form
|
||||
const emailInput = screen.queryByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.queryByPlaceholderText(/password/i);
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
// Fill out login form
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Wait for login to complete
|
||||
await waitFor(() => {
|
||||
// After successful login, should redirect or show authenticated content
|
||||
expect(localStorage.getItem('token')).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
it('should show error with invalid credentials', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.queryByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.queryByPlaceholderText(/password/i);
|
||||
|
||||
if (emailInput && passwordInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Wait for error handling
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protected Routes', () => {
|
||||
it('should redirect unauthenticated users from protected routes', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Unauthenticated users should not have access to certain features
|
||||
// This would depend on your routing configuration
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logout Flow', () => {
|
||||
it('should logout user and clear authentication state', async () => {
|
||||
// Set a mock token
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Look for logout button/link
|
||||
const logoutButtons = screen.queryAllByText(/logout/i);
|
||||
|
||||
if (logoutButtons.length > 0) {
|
||||
fireEvent.click(logoutButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Persistence', () => {
|
||||
it('should load user from token on app mount', async () => {
|
||||
// Set a valid token
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
// Should attempt to load user
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid token on app mount', async () => {
|
||||
// Set an invalid token
|
||||
localStorage.setItem('token', 'invalid-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Token should be cleared if invalid
|
||||
// This depends on the error handling in AuthContext
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should maintain authentication across page navigation', async () => {
|
||||
localStorage.setItem('token', 'mock-jwt-token');
|
||||
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Token should persist
|
||||
expect(localStorage.getItem('token')).toBe('mock-jwt-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<h2 className="alert-heading">Something went wrong</h2>
|
||||
<p>
|
||||
We're sorry, but something unexpected happened. Please try
|
||||
refreshing the page.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-3" style={{ whiteSpace: "pre-wrap" }}>
|
||||
<summary>Error details (development only)</summary>
|
||||
<p className="mt-2">
|
||||
<strong>Error:</strong> {this.state.error.toString()}
|
||||
</p>
|
||||
{this.state.errorInfo && (
|
||||
<p>
|
||||
<strong>Stack trace:</strong>
|
||||
<br />
|
||||
{this.state.errorInfo.componentStack}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
<hr />
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Events = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off, joinEvent, leaveEvent } = useContext(SocketContext);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joinedEvents, setJoinedEvents] = useState(new Set());
|
||||
|
||||
// Load events from API
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.get("/api/events");
|
||||
setEvents(res.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError("Failed to load events. Please try again later.");
|
||||
console.error("Error loading events:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// Handle real-time event updates
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleEventUpdate = (data) => {
|
||||
console.log("Received event update:", data);
|
||||
|
||||
if (data.type === "participants_updated") {
|
||||
// Update participant count for specific event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.map((event) =>
|
||||
event._id === data.eventId
|
||||
? { ...event, participants: data.participants }
|
||||
: event
|
||||
)
|
||||
);
|
||||
} else if (data.type === "new_event") {
|
||||
// Add new event to the list
|
||||
setEvents((prevEvents) => [data.event, ...prevEvents]);
|
||||
} else if (data.type === "event_updated") {
|
||||
// Update existing event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.map((event) =>
|
||||
event._id === data.event._id ? data.event : event
|
||||
)
|
||||
);
|
||||
} else if (data.type === "event_deleted") {
|
||||
// Remove deleted event
|
||||
setEvents((prevEvents) =>
|
||||
prevEvents.filter((event) => event._id !== data.eventId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to event updates
|
||||
on("eventUpdate", handleEventUpdate);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("eventUpdate", handleEventUpdate);
|
||||
|
||||
// Leave all joined event rooms
|
||||
joinedEvents.forEach((eventId) => {
|
||||
leaveEvent(eventId);
|
||||
});
|
||||
};
|
||||
}, [socket, connected, on, off, joinedEvents, leaveEvent]);
|
||||
|
||||
// Join event room when viewing events
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
// Join each event room for real-time updates
|
||||
events.forEach((event) => {
|
||||
if (!joinedEvents.has(event._id)) {
|
||||
joinEvent(event._id);
|
||||
setJoinedEvents((prev) => new Set([...prev, event._id]));
|
||||
}
|
||||
});
|
||||
}, [events, socket, connected, joinEvent, joinedEvents]);
|
||||
|
||||
const rsvp = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to RSVP for events");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/events/rsvp/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setEvents(
|
||||
events.map((event) =>
|
||||
event._id === id ? { ...event, participants: res.data } : event
|
||||
)
|
||||
);
|
||||
toast.success("Successfully RSVP'd to event!");
|
||||
} catch (err) {
|
||||
console.error("Error RSVPing to event:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to RSVP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading events...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
{error}
|
||||
<button className="btn btn-primary ml-3" onClick={loadEvents}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Events</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No events available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{events.map((event) => {
|
||||
const eventDate = new Date(event.date);
|
||||
const isUpcoming = eventDate > new Date();
|
||||
|
||||
return (
|
||||
<div key={event._id} className="col-md-6 mb-4">
|
||||
<div className="card h-100">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{event.title}</h5>
|
||||
<p className="card-text">{event.description}</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Date:</strong> {eventDate.toLocaleDateString()}{" "}
|
||||
{eventDate.toLocaleTimeString()}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Location:</strong> {event.location}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{event.organizer && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
<strong>Organizer:</strong>{" "}
|
||||
{event.organizer.name || event.organizer}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<span
|
||||
className={`badge badge-${
|
||||
isUpcoming ? "success" : "secondary"
|
||||
} mr-2`}
|
||||
>
|
||||
{isUpcoming ? "Upcoming" : "Past"}
|
||||
</span>
|
||||
<span className="badge badge-info">
|
||||
{event.participants?.length || 0} Participants
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isUpcoming && auth.isAuthenticated && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm mt-3 btn-block"
|
||||
onClick={() => rsvp(event._id)}
|
||||
>
|
||||
RSVP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Login = () => {
|
||||
const { auth, login } = useContext(AuthContext);
|
||||
const [formData, setFormData] = useState({ email: "", password: "" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { email, password } = formData;
|
||||
|
||||
const onChange = (e) =>
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (error) {
|
||||
console.error("Login submission error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="spinner-border mt-5" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6">
|
||||
<h1 className="text-center">Login</h1>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={onChange}
|
||||
placeholder="Email"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={onChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Logging in...
|
||||
</>
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
// Fix for default marker icons in react-leaflet
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
|
||||
iconUrl: require("leaflet/dist/images/marker-icon.png"),
|
||||
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
|
||||
});
|
||||
|
||||
// Custom marker icons for different street statuses
|
||||
const createCustomIcon = (color) => {
|
||||
return new L.Icon({
|
||||
iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${color}.png`,
|
||||
shadowUrl:
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
};
|
||||
|
||||
const availableIcon = createCustomIcon("green");
|
||||
const adoptedIcon = createCustomIcon("blue");
|
||||
const myStreetIcon = createCustomIcon("red");
|
||||
|
||||
// Component to handle map centering on user location
|
||||
const LocationMarker = ({ userLocation, setUserLocation }) => {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userLocation) {
|
||||
map
|
||||
.locate()
|
||||
.on("locationfound", (e) => {
|
||||
setUserLocation(e.latlng);
|
||||
map.flyTo(e.latlng, 13);
|
||||
})
|
||||
.on("locationerror", (e) => {
|
||||
console.warn("Location access denied:", e.message);
|
||||
});
|
||||
}
|
||||
}, [map, userLocation, setUserLocation]);
|
||||
|
||||
return userLocation === null ? null : (
|
||||
<Marker position={userLocation}>
|
||||
<Popup>You are here</Popup>
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
|
||||
const MapView = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [streets, setStreets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [userLocation, setUserLocation] = useState(null);
|
||||
const [selectedStreet, setSelectedStreet] = useState(null);
|
||||
const [adoptingStreetId, setAdoptingStreetId] = useState(null);
|
||||
const mapRef = useRef();
|
||||
|
||||
// Default center (can be changed to your city's coordinates)
|
||||
const defaultCenter = [40.7128, -74.006]; // New York City
|
||||
|
||||
useEffect(() => {
|
||||
loadStreets();
|
||||
}, []);
|
||||
|
||||
const loadStreets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/streets");
|
||||
setStreets(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading streets:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load streets. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adoptStreet = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to adopt a street");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAdoptingStreetId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/streets/adopt/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setStreets(
|
||||
streets.map((street) => (street._id === id ? res.data : street))
|
||||
);
|
||||
setSelectedStreet(null);
|
||||
toast.success("Street adopted successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error adopting street:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to adopt street. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setAdoptingStreetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getMarkerIcon = (street) => {
|
||||
if (
|
||||
street.adoptedBy &&
|
||||
auth.user &&
|
||||
street.adoptedBy._id === auth.user._id
|
||||
) {
|
||||
return myStreetIcon;
|
||||
}
|
||||
if (street.status === "available") {
|
||||
return availableIcon;
|
||||
}
|
||||
return adoptedIcon;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading map data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && streets.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Map</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadStreets}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3">Map View</h1>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png"
|
||||
alt="Available"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">Available</small>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png"
|
||||
alt="Adopted"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">Adopted</small>
|
||||
</span>
|
||||
<span className="mr-3">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png"
|
||||
alt="My Street"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<small className="ml-1">My Streets</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: "500px", width: "100%", marginBottom: "20px" }}>
|
||||
<MapContainer
|
||||
center={defaultCenter}
|
||||
zoom={13}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<LocationMarker
|
||||
userLocation={userLocation}
|
||||
setUserLocation={setUserLocation}
|
||||
/>
|
||||
|
||||
<MarkerClusterGroup>
|
||||
{streets.map((street) => {
|
||||
// Use street coordinates or generate random coordinates for demo
|
||||
const position = street.coordinates
|
||||
? [street.coordinates.lat, street.coordinates.lng]
|
||||
: [
|
||||
defaultCenter[0] + (Math.random() - 0.5) * 0.1,
|
||||
defaultCenter[1] + (Math.random() - 0.5) * 0.1,
|
||||
];
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={street._id}
|
||||
position={position}
|
||||
icon={getMarkerIcon(street)}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
setSelectedStreet(street);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<h5>{street.name}</h5>
|
||||
<p>
|
||||
<strong>Status:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available"
|
||||
? "success"
|
||||
: "primary"
|
||||
}`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
</p>
|
||||
{street.adoptedBy && (
|
||||
<p>
|
||||
<strong>Adopted by:</strong> {street.adoptedBy.name}
|
||||
</p>
|
||||
)}
|
||||
{street.description && (
|
||||
<p>
|
||||
<strong>Description:</strong> {street.description}
|
||||
</p>
|
||||
)}
|
||||
{street.status === "available" && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => adoptStreet(street._id)}
|
||||
disabled={adoptingStreetId === street._id}
|
||||
>
|
||||
{adoptingStreetId === street._id
|
||||
? "Adopting..."
|
||||
: "Adopt This Street"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{selectedStreet && (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{selectedStreet.name}</h5>
|
||||
<p className="card-text">
|
||||
<strong>Status:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
selectedStreet.status === "available" ? "success" : "primary"
|
||||
}`}
|
||||
>
|
||||
{selectedStreet.status}
|
||||
</span>
|
||||
</p>
|
||||
{selectedStreet.adoptedBy && (
|
||||
<p className="card-text">
|
||||
<strong>Adopted by:</strong> {selectedStreet.adoptedBy.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedStreet.description && (
|
||||
<p className="card-text">
|
||||
<strong>Description:</strong> {selectedStreet.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedStreet.status === "available" && (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => adoptStreet(selectedStreet._id)}
|
||||
disabled={adoptingStreetId === selectedStreet._id}
|
||||
>
|
||||
{adoptingStreetId === selectedStreet._id
|
||||
? "Adopting..."
|
||||
: "Adopt This Street"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary ml-2"
|
||||
onClick={() => setSelectedStreet(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<h3>Street List</h3>
|
||||
{streets.length === 0 ? (
|
||||
<p className="text-muted">No streets available at the moment.</p>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{streets.map((street) => (
|
||||
<li key={street._id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{street.name}</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available" ? "success" : "primary"
|
||||
}`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
{street.adoptedBy && (
|
||||
<small className="text-muted ml-2">
|
||||
by {street.adoptedBy.name}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{street.status === "available" && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => adoptStreet(street._id)}
|
||||
disabled={adoptingStreetId === street._id}
|
||||
>
|
||||
{adoptingStreetId === street._id ? "Adopting..." : "Adopt"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AuthContext } from '../context/AuthContext';
|
||||
|
||||
const Navbar = () => {
|
||||
const { auth, logout } = useContext(AuthContext);
|
||||
|
||||
const authLinks = (
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/map">Map</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/tasks">Tasks</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/feed">Feed</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/events">Events</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/rewards">Rewards</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/profile">Profile</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/premium">Premium</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a onClick={logout} href="#!" className="nav-link">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
||||
const guestLinks = (
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/register">Register</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/login">Login</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand-sm navbar-dark bg-dark mb-4">
|
||||
<div className="container">
|
||||
<Link className="navbar-brand" to="/">Adopt-a-Street</Link>
|
||||
<div className="collapse navbar-collapse">
|
||||
{auth.isAuthenticated ? authLinks : guestLinks}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Premium component displays premium subscription information and allows users to subscribe
|
||||
*/
|
||||
const Premium = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
|
||||
// Subscribe to premium
|
||||
const subscribe = async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to subscribe to premium");
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.user?.isPremium) {
|
||||
toast.info("You are already a premium member!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubscribing(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.post(
|
||||
"/api/payments/subscribe",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
toast.success(
|
||||
res.data.message || "Subscription successful! Welcome to premium!"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error subscribing:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to subscribe. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Premium Subscription</h1>
|
||||
|
||||
{auth.user?.isPremium ? (
|
||||
<div className="alert alert-success mb-4">
|
||||
<h4 className="alert-heading">You are a Premium Member!</h4>
|
||||
<p>
|
||||
Thank you for your support! You have access to all premium features.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-info mb-4">
|
||||
<h4 className="alert-heading">Upgrade to Premium</h4>
|
||||
<p>
|
||||
Unlock exclusive rewards and features by subscribing to our premium
|
||||
plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8 offset-md-2">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-center mb-4">Premium Benefits</h3>
|
||||
|
||||
<ul className="list-group list-group-flush mb-4">
|
||||
<li className="list-group-item">
|
||||
<strong>Exclusive Rewards:</strong> Access to premium-only
|
||||
rewards in the rewards catalog
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Priority Support:</strong> Get faster responses from
|
||||
our support team
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Advanced Features:</strong> Unlock advanced analytics
|
||||
and reporting tools
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Special Badge:</strong> Display your premium status
|
||||
with a special badge
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Early Access:</strong> Be the first to try new
|
||||
features and updates
|
||||
</li>
|
||||
<li className="list-group-item">
|
||||
<strong>Ad-Free Experience:</strong> Enjoy the platform
|
||||
without any advertisements
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="text-primary">$9.99/month</h2>
|
||||
<p className="text-muted">Cancel anytime, no commitments</p>
|
||||
</div>
|
||||
|
||||
{!auth.isAuthenticated ? (
|
||||
<div className="alert alert-warning text-center">
|
||||
<p className="mb-2">
|
||||
Please log in to subscribe to premium.
|
||||
</p>
|
||||
<a href="/login" className="btn btn-primary">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
) : auth.user?.isPremium ? (
|
||||
<div className="text-center">
|
||||
<button className="btn btn-success btn-lg" disabled>
|
||||
Already Subscribed
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
onClick={subscribe}
|
||||
disabled={subscribing}
|
||||
>
|
||||
{subscribing ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Subscribe Now"
|
||||
)}
|
||||
</button>
|
||||
<p className="text-muted mt-3">
|
||||
<small>
|
||||
Note: This is a mock subscription for development
|
||||
purposes. No actual payment will be processed.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt-5">
|
||||
<div className="col-md-12">
|
||||
<h3 className="text-center mb-4">Frequently Asked Questions</h3>
|
||||
|
||||
<div className="accordion" id="faqAccordion">
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq1">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse1"
|
||||
aria-expanded="true"
|
||||
aria-controls="collapse1"
|
||||
>
|
||||
Can I cancel my subscription anytime?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse1"
|
||||
className="collapse"
|
||||
aria-labelledby="faq1"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
Yes! You can cancel your premium subscription at any time.
|
||||
Your premium benefits will remain active until the end of your
|
||||
current billing period.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq2">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link collapsed"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse2"
|
||||
aria-expanded="false"
|
||||
aria-controls="collapse2"
|
||||
>
|
||||
What payment methods do you accept?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse2"
|
||||
className="collapse"
|
||||
aria-labelledby="faq2"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
We accept all major credit cards (Visa, MasterCard, American
|
||||
Express) and PayPal. All payments are processed securely
|
||||
through Stripe.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header" id="faq3">
|
||||
<h5 className="mb-0">
|
||||
<button
|
||||
className="btn btn-link collapsed"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#collapse3"
|
||||
aria-expanded="false"
|
||||
aria-controls="collapse3"
|
||||
>
|
||||
Will my points carry over if I upgrade?
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div
|
||||
id="collapse3"
|
||||
className="collapse"
|
||||
aria-labelledby="faq3"
|
||||
data-parent="#faqAccordion"
|
||||
>
|
||||
<div className="card-body">
|
||||
Absolutely! All your existing points, badges, and adopted
|
||||
streets will remain unchanged when you upgrade to premium.
|
||||
You'll simply gain access to additional premium features and
|
||||
rewards.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Premium;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Profile component displays user profile information, adopted streets, and badges
|
||||
*/
|
||||
const Profile = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load user profile from API
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!auth.user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const userId = auth.user.id || auth.user._id;
|
||||
const res = await axios.get(`/api/users/${userId}`);
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading profile:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load profile. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Profile</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadProfile}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return (
|
||||
<div className="alert alert-warning m-3" role="alert">
|
||||
<h4 className="alert-heading">Not Logged In</h4>
|
||||
<p>Please log in to view your profile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="alert alert-info m-3" role="alert">
|
||||
<h4 className="alert-heading">No Profile Data</h4>
|
||||
<p>Unable to load profile information.</p>
|
||||
<button className="btn btn-primary" onClick={loadProfile}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}'s Profile</h1>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Profile Information</h5>
|
||||
<p className="card-text">
|
||||
<strong>Email:</strong> {user.email}
|
||||
</p>
|
||||
<p className="card-text">
|
||||
<strong>Points:</strong>{" "}
|
||||
<span className="badge badge-primary">{user.points || 0}</span>
|
||||
</p>
|
||||
{user.isPremium && (
|
||||
<p className="card-text">
|
||||
<span className="badge badge-warning">Premium Member</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Adopted Streets</h5>
|
||||
{user.adoptedStreets && user.adoptedStreets.length > 0 ? (
|
||||
<ul className="list-group">
|
||||
{user.adoptedStreets.map((street) => (
|
||||
<li key={street._id || street} className="list-group-item">
|
||||
{street.name || street}
|
||||
{street.status && (
|
||||
<span
|
||||
className={`badge badge-${
|
||||
street.status === "available" ? "success" : "primary"
|
||||
} ml-2`}
|
||||
>
|
||||
{street.status}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">
|
||||
You haven't adopted any streets yet. Visit the map to adopt a
|
||||
street!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Badges</h5>
|
||||
{user.badges && user.badges.length > 0 ? (
|
||||
<ul className="list-group">
|
||||
{user.badges.map((badge, index) => (
|
||||
<li key={index} className="list-group-item">
|
||||
<span className="badge badge-success mr-2">
|
||||
{badge}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">
|
||||
No badges earned yet. Complete tasks and participate in events to
|
||||
earn badges!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.tasksCompleted !== undefined && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Statistics</h5>
|
||||
<p className="card-text">
|
||||
<strong>Tasks Completed:</strong>{" "}
|
||||
<span className="badge badge-info">{user.tasksCompleted}</span>
|
||||
</p>
|
||||
{user.eventsAttended !== undefined && (
|
||||
<p className="card-text">
|
||||
<strong>Events Attended:</strong>{" "}
|
||||
<span className="badge badge-info">{user.eventsAttended}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
const Register = () => {
|
||||
const { auth, register } = useContext(AuthContext);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { name, email, password } = formData;
|
||||
|
||||
const onChange = (e) =>
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(name, email, password);
|
||||
} catch (error) {
|
||||
console.error("Registration submission error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="spinner-border mt-5" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6">
|
||||
<h1 className="text-center">Register</h1>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="form-control"
|
||||
value={name}
|
||||
onChange={onChange}
|
||||
placeholder="Name"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="form-control"
|
||||
value={email}
|
||||
onChange={onChange}
|
||||
placeholder="Email"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={onChange}
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* Rewards component displays available rewards and allows users to redeem them
|
||||
*/
|
||||
const Rewards = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [rewards, setRewards] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [redeemingRewardId, setRedeemingRewardId] = useState(null);
|
||||
|
||||
// Load rewards from API
|
||||
const loadRewards = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/rewards");
|
||||
setRewards(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading rewards:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load rewards. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRewards();
|
||||
}, [loadRewards]);
|
||||
|
||||
// Redeem a reward
|
||||
const redeemReward = async (id, rewardName, cost) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to redeem rewards");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRedeemingRewardId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
await axios.post(
|
||||
`/api/rewards/redeem/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
toast.success(
|
||||
`Reward "${rewardName}" redeemed successfully! ${cost} points deducted.`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error redeeming reward:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to redeem reward. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setRedeemingRewardId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading rewards...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Rewards</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadRewards}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Rewards</h1>
|
||||
|
||||
{auth.user && (
|
||||
<div className="alert alert-info mb-4">
|
||||
<strong>Your Points:</strong>{" "}
|
||||
<span className="badge badge-primary">{auth.user.points || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!auth.isAuthenticated && (
|
||||
<div className="alert alert-warning mb-4">
|
||||
Please log in to view and redeem rewards.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rewards.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No rewards available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{rewards.map((reward) => {
|
||||
const canAfford =
|
||||
auth.isAuthenticated &&
|
||||
auth.user &&
|
||||
(auth.user.points || 0) >= reward.cost;
|
||||
const canRedeem =
|
||||
canAfford &&
|
||||
(!reward.isPremium ||
|
||||
(reward.isPremium && auth.user.isPremium));
|
||||
|
||||
return (
|
||||
<div key={reward._id} className="col-md-6 col-lg-4 mb-4">
|
||||
<div className="card h-100">
|
||||
<div className="card-body d-flex flex-column">
|
||||
<h5 className="card-title">{reward.name}</h5>
|
||||
<p className="card-text flex-grow-1">
|
||||
{reward.description}
|
||||
</p>
|
||||
|
||||
<div className="mb-2">
|
||||
<strong>Cost:</strong>{" "}
|
||||
<span
|
||||
className={`badge badge-${
|
||||
canAfford ? "success" : "warning"
|
||||
}`}
|
||||
>
|
||||
{reward.cost} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{reward.isPremium && (
|
||||
<div className="mb-2">
|
||||
<span className="badge badge-warning">
|
||||
Premium Only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reward.quantity !== undefined && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">
|
||||
Available: {reward.quantity}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{auth.isAuthenticated ? (
|
||||
<button
|
||||
className={`btn btn-${
|
||||
canRedeem ? "primary" : "secondary"
|
||||
} btn-block mt-2`}
|
||||
onClick={() =>
|
||||
redeemReward(reward._id, reward.name, reward.cost)
|
||||
}
|
||||
disabled={
|
||||
!canRedeem ||
|
||||
redeemingRewardId === reward._id ||
|
||||
(reward.quantity !== undefined && reward.quantity <= 0)
|
||||
}
|
||||
>
|
||||
{redeemingRewardId === reward._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Redeeming...
|
||||
</>
|
||||
) : !canAfford ? (
|
||||
"Insufficient Points"
|
||||
) : reward.isPremium && !auth.user.isPremium ? (
|
||||
"Premium Required"
|
||||
) : reward.quantity !== undefined &&
|
||||
reward.quantity <= 0 ? (
|
||||
"Out of Stock"
|
||||
) : (
|
||||
"Redeem"
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-block mt-2" disabled>
|
||||
Login to Redeem
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rewards;
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
|
||||
/**
|
||||
* SocialFeed component displays community posts and allows creating new posts
|
||||
* Includes real-time updates via Socket.IO
|
||||
*/
|
||||
const SocialFeed = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [likingPostId, setLikingPostId] = useState(null);
|
||||
|
||||
// Load posts from API
|
||||
const loadPosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/posts");
|
||||
setPosts(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading posts:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load posts. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
// Handle real-time post updates via Socket.IO
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleNewPost = (data) => {
|
||||
console.log("Received new post:", data);
|
||||
setPosts((prevPosts) => [data.post, ...prevPosts]);
|
||||
toast.info("New post added to feed!");
|
||||
};
|
||||
|
||||
const handlePostUpdate = (data) => {
|
||||
console.log("Received post update:", data);
|
||||
|
||||
if (data.type === "post_liked") {
|
||||
// Update specific post likes
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.postId ? { ...post, likes: data.likes } : post
|
||||
)
|
||||
);
|
||||
} else if (data.type === "post_updated") {
|
||||
// Update existing post
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.post._id ? data.post : post
|
||||
)
|
||||
);
|
||||
} else if (data.type === "post_deleted") {
|
||||
// Remove deleted post
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.filter((post) => post._id !== data.postId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewComment = (data) => {
|
||||
console.log("Received new comment:", data);
|
||||
// Update post with new comment
|
||||
setPosts((prevPosts) =>
|
||||
prevPosts.map((post) =>
|
||||
post._id === data.postId
|
||||
? { ...post, comments: [...(post.comments || []), data.comment] }
|
||||
: post
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Subscribe to post events
|
||||
on("newPost", handleNewPost);
|
||||
on("postUpdate", handlePostUpdate);
|
||||
on("newComment", handleNewComment);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("newPost", handleNewPost);
|
||||
off("postUpdate", handlePostUpdate);
|
||||
off("newComment", handleNewComment);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
|
||||
// Like a post
|
||||
const likePost = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to like posts");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLikingPostId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/posts/like/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setPosts(
|
||||
posts.map((post) =>
|
||||
post._id === id ? { ...post, likes: res.data } : post
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error liking post:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to like post. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLikingPostId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Submit a new post
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to create posts");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
toast.warning("Post content cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.post(
|
||||
"/api/posts",
|
||||
{ content },
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setPosts([res.data, ...posts]);
|
||||
setContent("");
|
||||
toast.success("Post created successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error creating post:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to create post. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading posts...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Posts</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadPosts}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Social Feed</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{auth.isAuthenticated && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Create a Post</h5>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="What's on your mind?"
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || !content.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
"Post"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No posts yet. Be the first to share something!
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{posts.map((post) => (
|
||||
<li key={post._id} className="list-group-item">
|
||||
<div className="mb-2">
|
||||
<p className="mb-2">{post.content}</p>
|
||||
<small className="text-muted">
|
||||
By: <strong>{post.user?.name || "Unknown User"}</strong>
|
||||
{post.createdAt && (
|
||||
<span className="ml-2">
|
||||
{new Date(post.createdAt).toLocaleDateString()}{" "}
|
||||
{new Date(post.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => likePost(post._id)}
|
||||
disabled={!auth.isAuthenticated || likingPostId === post._id}
|
||||
>
|
||||
{likingPostId === post._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Liking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Like ({post.likes?.length || 0})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{post.comments && post.comments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<small className="text-muted">
|
||||
{post.comments.length} comment{post.comments.length !== 1 ? "s" : ""}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialFeed;
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { SocketContext } from "../context/SocketContext";
|
||||
|
||||
/**
|
||||
* TaskList component displays maintenance tasks and allows task completion
|
||||
* Includes real-time updates via Socket.IO
|
||||
*/
|
||||
const TaskList = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const { socket, connected, on, off } = useContext(SocketContext);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [completingTaskId, setCompletingTaskId] = useState(null);
|
||||
|
||||
// Load tasks from API
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await axios.get("/api/tasks");
|
||||
setTasks(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading tasks:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load tasks. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// Handle real-time task updates via Socket.IO
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
const handleTaskUpdate = (data) => {
|
||||
console.log("Received task update:", data);
|
||||
|
||||
if (data.type === "task_completed") {
|
||||
// Update specific task status
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task._id === data.taskId ? { ...task, status: "completed" } : task
|
||||
)
|
||||
);
|
||||
toast.success("Task completed!");
|
||||
} else if (data.type === "new_task") {
|
||||
// Add new task to the list
|
||||
setTasks((prevTasks) => [data.task, ...prevTasks]);
|
||||
toast.info("New task available!");
|
||||
} else if (data.type === "task_updated") {
|
||||
// Update existing task
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task._id === data.task._id ? data.task : task
|
||||
)
|
||||
);
|
||||
} else if (data.type === "task_deleted") {
|
||||
// Remove deleted task
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.filter((task) => task._id !== data.taskId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to task updates
|
||||
on("taskUpdate", handleTaskUpdate);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
off("taskUpdate", handleTaskUpdate);
|
||||
};
|
||||
}, [socket, connected, on, off]);
|
||||
|
||||
// Complete a task
|
||||
const completeTask = async (id) => {
|
||||
if (!auth.isAuthenticated) {
|
||||
toast.warning("Please login to complete tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCompletingTaskId(id);
|
||||
const token = localStorage.getItem("token");
|
||||
const res = await axios.put(
|
||||
`/api/tasks/${id}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
}
|
||||
);
|
||||
setTasks(tasks.map((task) => (task._id === id ? res.data : task)));
|
||||
toast.success("Task completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error completing task:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to complete task. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setCompletingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Tasks</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadTasks}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Task List</h1>
|
||||
{connected && (
|
||||
<span className="badge badge-success">
|
||||
<span className="mr-1">●</span> Live Updates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
No tasks available at the moment. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-group">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task._id}
|
||||
className="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div>
|
||||
<strong>{task.description}</strong>
|
||||
<br />
|
||||
<span
|
||||
className={`badge badge-${
|
||||
task.status === "pending" ? "warning" : "success"
|
||||
} mr-2`}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.street && (
|
||||
<small className="text-muted">Street: {task.street.name || task.street}</small>
|
||||
)}
|
||||
{task.assignedTo && (
|
||||
<small className="text-muted ml-2">
|
||||
Assigned to: {task.assignedTo.name || task.assignedTo}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{task.status === "pending" && auth.isAuthenticated && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => completeTask(task._id)}
|
||||
disabled={completingTaskId === task._id}
|
||||
>
|
||||
{completingTaskId === task._id ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Completing...
|
||||
</>
|
||||
) : (
|
||||
"Complete"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
|
||||
// Component that throws an error
|
||||
const ThrowError = ({ shouldThrow }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error');
|
||||
}
|
||||
return <div>No Error</div>;
|
||||
};
|
||||
|
||||
describe('ErrorBoundary Component', () => {
|
||||
beforeEach(() => {
|
||||
// Suppress console errors during error boundary tests
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error.mockRestore();
|
||||
});
|
||||
|
||||
describe('Normal Rendering', () => {
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Child Component</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Child Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Child 1</div>
|
||||
<div>Child 2</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should catch errors and display fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error message in fallback UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/please try refreshing/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render children when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('No Error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should catch errors in nested components', () => {
|
||||
const NestedComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NestedComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh Button', () => {
|
||||
it('should display a refresh button in error state', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reload page when refresh button is clicked', () => {
|
||||
// Mock window.location.reload
|
||||
delete window.location;
|
||||
window.location = { reload: jest.fn() };
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
|
||||
refreshButton.click();
|
||||
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Updates', () => {
|
||||
it('should update state when error is caught', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Error')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Info', () => {
|
||||
it('should not crash when rendering error boundary', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should log error to console', () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error');
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Styling', () => {
|
||||
it('should render error container with proper classes', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const container = screen.getByRole('heading', { name: /something went wrong/i }).closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Login from '../Login';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
|
||||
// Mock useNavigate
|
||||
const mockedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Navigate: ({ to }) => {
|
||||
mockedNavigate(to);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Login Component', () => {
|
||||
const mockLogin = jest.fn();
|
||||
|
||||
const mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
const renderLogin = (contextValue = mockAuthContext) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
<Login />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogin.mockClear();
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render login form', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render email input field', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(emailInput).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should render password input field', () => {
|
||||
renderLogin();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(passwordInput).toHaveAttribute('required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should update email field on change', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
expect(emailInput).toHaveValue('test@example.com');
|
||||
});
|
||||
|
||||
it('should update password field on change', () => {
|
||||
renderLogin();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('should have required fields', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
|
||||
expect(emailInput).toBeRequired();
|
||||
expect(passwordInput).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call login function on form submit', async () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable form fields during submission', async () => {
|
||||
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(passwordInput).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/logging in/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle login errors gracefully', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockLogin.mockRejectedValue(new Error('Login failed'));
|
||||
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrong' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should redirect to /map when already authenticated', () => {
|
||||
const authenticatedContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: { name: 'Test User' },
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(authenticatedContext);
|
||||
|
||||
expect(mockedNavigate).toHaveBeenCalledWith('/map');
|
||||
});
|
||||
|
||||
it('should show loading spinner when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(loadingContext);
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show form when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
login: mockLogin,
|
||||
};
|
||||
|
||||
renderLogin(loadingContext);
|
||||
|
||||
expect(screen.queryByPlaceholderText(/email/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/password/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible form elements', () => {
|
||||
renderLogin();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
|
||||
expect(emailInput).toHaveAttribute('name', 'email');
|
||||
expect(passwordInput).toHaveAttribute('name', 'password');
|
||||
});
|
||||
|
||||
it('should have accessible button', () => {
|
||||
renderLogin();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty Form Submission', () => {
|
||||
it('should not submit with empty fields', () => {
|
||||
renderLogin();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(mockLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Register from '../Register';
|
||||
import { AuthContext } from '../../context/AuthContext';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Navigate: ({ to }) => {
|
||||
mockedNavigate(to);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Register Component', () => {
|
||||
const mockRegister = jest.fn();
|
||||
|
||||
const mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
user: null,
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
const renderRegister = (contextValue = mockAuthContext) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
<Register />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRegister.mockClear();
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render registration form', () => {
|
||||
renderRegister();
|
||||
|
||||
expect(screen.getByRole('heading', { name: /register/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/^password$/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/confirm password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all required input fields', () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
expect(nameInput).toBeRequired();
|
||||
expect(emailInput).toBeRequired();
|
||||
expect(passwordInput).toBeRequired();
|
||||
expect(confirmPasswordInput).toBeRequired();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Input Changes', () => {
|
||||
it('should update name field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
|
||||
expect(nameInput).toHaveValue('John Doe');
|
||||
});
|
||||
|
||||
it('should update email field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
|
||||
expect(emailInput).toHaveValue('john@example.com');
|
||||
});
|
||||
|
||||
it('should update password field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(passwordInput).toHaveValue('password123');
|
||||
});
|
||||
|
||||
it('should update confirm password field on change', () => {
|
||||
renderRegister();
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(confirmPasswordInput).toHaveValue('password123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call register function with valid data', async () => {
|
||||
mockRegister.mockResolvedValue({ success: true });
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegister).toHaveBeenCalledWith('John Doe', 'john@example.com', 'password123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable form during submission', async () => {
|
||||
mockRegister.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nameInput).toBeDisabled();
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(passwordInput).toBeDisabled();
|
||||
expect(confirmPasswordInput).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Validation', () => {
|
||||
it('should validate minimum password length', async () => {
|
||||
renderRegister();
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '12345' } });
|
||||
|
||||
// Password should have minLength attribute
|
||||
expect(passwordInput).toHaveAttribute('minLength');
|
||||
});
|
||||
|
||||
it('should show error when passwords do not match', async () => {
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'different' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Should not call register if passwords don't match
|
||||
expect(mockRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication State', () => {
|
||||
it('should redirect to /map when already authenticated', () => {
|
||||
const authenticatedContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
user: { name: 'Test User' },
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
renderRegister(authenticatedContext);
|
||||
|
||||
expect(mockedNavigate).toHaveBeenCalledWith('/map');
|
||||
});
|
||||
|
||||
it('should show loading spinner when auth is loading', () => {
|
||||
const loadingContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
user: null,
|
||||
},
|
||||
register: mockRegister,
|
||||
};
|
||||
|
||||
renderRegister(loadingContext);
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field Types', () => {
|
||||
it('should have correct input types', () => {
|
||||
renderRegister();
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle registration errors', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockRegister.mockRejectedValue(new Error('Registration failed'));
|
||||
|
||||
renderRegister();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/name/i);
|
||||
const emailInput = screen.getByPlaceholderText(/email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/^password$/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
|
||||
const submitButton = screen.getByRole('button', { name: /register/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
|
||||
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const [auth, setAuth] = useState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
axios.defaults.headers.common["x-auth-token"] = token;
|
||||
try {
|
||||
const res = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
user: res.data,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load user:", error);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const res = await axios.post("/api/auth/login", { email, password });
|
||||
localStorage.setItem("token", res.data.token);
|
||||
axios.defaults.headers.common["x-auth-token"] = res.data.token;
|
||||
|
||||
try {
|
||||
const userRes = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token: res.data.token,
|
||||
isAuthenticated: true,
|
||||
user: userRes.data,
|
||||
loading: false,
|
||||
});
|
||||
toast.success("Login successful!");
|
||||
return { success: true };
|
||||
} catch (userError) {
|
||||
console.error("Failed to fetch user after login:", userError);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
toast.error("Login succeeded but failed to load user data");
|
||||
return { success: false, error: "Failed to load user data" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (name, email, password) => {
|
||||
try {
|
||||
const res = await axios.post("/api/auth/register", {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
localStorage.setItem("token", res.data.token);
|
||||
axios.defaults.headers.common["x-auth-token"] = res.data.token;
|
||||
|
||||
try {
|
||||
const userRes = await axios.get("/api/auth");
|
||||
setAuth({
|
||||
token: res.data.token,
|
||||
isAuthenticated: true,
|
||||
user: userRes.data,
|
||||
loading: false,
|
||||
});
|
||||
toast.success("Registration successful! Welcome aboard!");
|
||||
return { success: true };
|
||||
} catch (userError) {
|
||||
console.error("Failed to fetch user after registration:", userError);
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
toast.error("Registration succeeded but failed to load user data");
|
||||
return { success: false, error: "Failed to load user data" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
"Registration failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
delete axios.defaults.headers.common["x-auth-token"];
|
||||
setAuth({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
});
|
||||
toast.info("You have been logged out");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ auth, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
/**
|
||||
* SocketProvider manages WebSocket connections and real-time event handling
|
||||
* Automatically reconnects on disconnection and provides event subscription methods
|
||||
*/
|
||||
const SocketProvider = ({ children }) => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize socket connection
|
||||
const socketInstance = io("http://localhost:5000", {
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
socketInstance.on("connect", () => {
|
||||
console.log("Socket.IO connected:", socketInstance.id);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("disconnect", (reason) => {
|
||||
console.log("Socket.IO disconnected:", reason);
|
||||
setConnected(false);
|
||||
|
||||
// Automatically reconnect if disconnection was unexpected
|
||||
if (reason === "io server disconnect") {
|
||||
// Server initiated disconnect, reconnect manually
|
||||
socketInstance.connect();
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on("connect_error", (error) => {
|
||||
console.error("Socket.IO connection error:", error);
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnected after", attemptNumber, "attempts");
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_attempt", (attemptNumber) => {
|
||||
console.log("Socket.IO reconnection attempt", attemptNumber);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_error", (error) => {
|
||||
console.error("Socket.IO reconnection error:", error);
|
||||
});
|
||||
|
||||
socketInstance.on("reconnect_failed", () => {
|
||||
console.error("Socket.IO reconnection failed");
|
||||
});
|
||||
|
||||
// Generic notification handler
|
||||
socketInstance.on("notification", (data) => {
|
||||
console.log("Received notification:", data);
|
||||
setNotifications((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
...data,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Connect socket if user is authenticated
|
||||
if (auth.isAuthenticated) {
|
||||
socketInstance.connect();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
socketInstance.removeAllListeners();
|
||||
};
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Join a specific event room
|
||||
const joinEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Joining event room:", eventId);
|
||||
socket.emit("joinEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Leave a specific event room
|
||||
const leaveEvent = useCallback(
|
||||
(eventId) => {
|
||||
if (socket && connected) {
|
||||
console.log("Leaving event room:", eventId);
|
||||
socket.emit("leaveEvent", eventId);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Subscribe to a specific event
|
||||
const on = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.on(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Unsubscribe from a specific event
|
||||
const off = useCallback(
|
||||
(event, callback) => {
|
||||
if (socket) {
|
||||
socket.off(event, callback);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
// Emit an event
|
||||
const emit = useCallback(
|
||||
(event, data) => {
|
||||
if (socket && connected) {
|
||||
socket.emit(event, data);
|
||||
}
|
||||
},
|
||||
[socket, connected]
|
||||
);
|
||||
|
||||
// Clear a notification
|
||||
const clearNotification = useCallback((notificationId) => {
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notification) => notification.id !== notificationId)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clear all notifications
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
socket,
|
||||
connected,
|
||||
notifications,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
clearNotification,
|
||||
clearAllNotifications,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocketProvider;
|
||||
|
||||
/**
|
||||
* Custom hook to use socket context
|
||||
* @returns {Object} Socket context value
|
||||
*/
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocket must be used within a SocketProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// Mock API base URL (matches the proxy in package.json)
|
||||
const API_URL = 'http://localhost:5000';
|
||||
|
||||
// Mock data
|
||||
const mockUser = {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
isPremium: false,
|
||||
points: 100,
|
||||
adoptedStreets: [],
|
||||
completedTasks: [],
|
||||
posts: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const mockStreets = [
|
||||
{
|
||||
_id: 'street1',
|
||||
name: 'Main Street',
|
||||
city: 'Test City',
|
||||
state: 'TS',
|
||||
location: {
|
||||
type: 'Point',
|
||||
coordinates: [-73.935242, 40.730610],
|
||||
},
|
||||
adoptedBy: 'user123',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
_id: 'street2',
|
||||
name: 'Oak Avenue',
|
||||
city: 'Test City',
|
||||
state: 'TS',
|
||||
location: {
|
||||
type: 'Point',
|
||||
coordinates: [-73.945242, 40.740610],
|
||||
},
|
||||
adoptedBy: 'user456',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTasks = [
|
||||
{
|
||||
_id: 'task1',
|
||||
street: 'street1',
|
||||
description: 'Clean up litter',
|
||||
type: 'cleaning',
|
||||
status: 'pending',
|
||||
createdBy: 'user123',
|
||||
},
|
||||
{
|
||||
_id: 'task2',
|
||||
street: 'street1',
|
||||
description: 'Fix pothole',
|
||||
type: 'repair',
|
||||
status: 'completed',
|
||||
createdBy: 'user123',
|
||||
},
|
||||
];
|
||||
|
||||
const mockPosts = [
|
||||
{
|
||||
_id: 'post1',
|
||||
user: {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
profilePicture: null,
|
||||
},
|
||||
content: 'Just cleaned up Main Street!',
|
||||
type: 'text',
|
||||
likes: [],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
_id: 'post2',
|
||||
user: {
|
||||
_id: 'user456',
|
||||
name: 'Another User',
|
||||
profilePicture: null,
|
||||
},
|
||||
content: 'Great work everyone!',
|
||||
type: 'text',
|
||||
likes: ['user123'],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
_id: 'event1',
|
||||
title: 'Community Cleanup',
|
||||
description: 'Annual community cleanup event',
|
||||
date: new Date(Date.now() + 86400000).toISOString(),
|
||||
location: 'Central Park',
|
||||
organizer: 'user123',
|
||||
participants: [],
|
||||
},
|
||||
{
|
||||
_id: 'event2',
|
||||
title: 'Tree Planting Day',
|
||||
description: 'Help us plant trees in the neighborhood',
|
||||
date: new Date(Date.now() + 172800000).toISOString(),
|
||||
location: 'Riverside Park',
|
||||
organizer: 'user456',
|
||||
participants: ['user123'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRewards = [
|
||||
{
|
||||
_id: 'reward1',
|
||||
name: 'Bronze Badge',
|
||||
description: 'Complete 5 tasks',
|
||||
pointsCost: 50,
|
||||
isPremium: false,
|
||||
},
|
||||
{
|
||||
_id: 'reward2',
|
||||
name: 'Silver Badge',
|
||||
description: 'Complete 20 tasks',
|
||||
pointsCost: 150,
|
||||
isPremium: false,
|
||||
},
|
||||
{
|
||||
_id: 'reward3',
|
||||
name: 'Premium Badge',
|
||||
description: 'Exclusive premium badge',
|
||||
pointsCost: 200,
|
||||
isPremium: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Auth handlers
|
||||
const authHandlers = [
|
||||
// Register
|
||||
http.post(`${API_URL}/api/auth/register`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({
|
||||
token: 'mock-jwt-token',
|
||||
});
|
||||
}),
|
||||
|
||||
// Login
|
||||
http.post(`${API_URL}/api/auth/login`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body.email === 'test@example.com' && body.password === 'password123') {
|
||||
return HttpResponse.json({
|
||||
token: 'mock-jwt-token',
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Invalid credentials' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}),
|
||||
|
||||
// Get authenticated user
|
||||
http.get(`${API_URL}/api/auth`, ({ request }) => {
|
||||
const token = request.headers.get('x-auth-token');
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'No token, authorization denied' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json(mockUser);
|
||||
}),
|
||||
];
|
||||
|
||||
// Streets handlers
|
||||
const streetsHandlers = [
|
||||
// Get all streets
|
||||
http.get(`${API_URL}/api/streets`, () => {
|
||||
return HttpResponse.json(mockStreets);
|
||||
}),
|
||||
|
||||
// Adopt a street
|
||||
http.put(`${API_URL}/api/streets/adopt/:id`, ({ params }) => {
|
||||
const street = mockStreets.find(s => s._id === params.id);
|
||||
|
||||
if (!street) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Street not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...street,
|
||||
adoptedBy: 'user123',
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Tasks handlers
|
||||
const tasksHandlers = [
|
||||
// Get all tasks
|
||||
http.get(`${API_URL}/api/tasks`, () => {
|
||||
return HttpResponse.json(mockTasks);
|
||||
}),
|
||||
|
||||
// Create a task
|
||||
http.post(`${API_URL}/api/tasks`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newTask = {
|
||||
_id: `task${Date.now()}`,
|
||||
...body,
|
||||
status: 'pending',
|
||||
createdBy: 'user123',
|
||||
};
|
||||
return HttpResponse.json(newTask);
|
||||
}),
|
||||
|
||||
// Complete a task
|
||||
http.put(`${API_URL}/api/tasks/:id/complete`, ({ params }) => {
|
||||
const task = mockTasks.find(t => t._id === params.id);
|
||||
|
||||
if (!task) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Task not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...task,
|
||||
status: 'completed',
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Posts handlers
|
||||
const postsHandlers = [
|
||||
// Get all posts
|
||||
http.get(`${API_URL}/api/posts`, () => {
|
||||
return HttpResponse.json(mockPosts);
|
||||
}),
|
||||
|
||||
// Create a post
|
||||
http.post(`${API_URL}/api/posts`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newPost = {
|
||||
_id: `post${Date.now()}`,
|
||||
user: {
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
profilePicture: null,
|
||||
},
|
||||
...body,
|
||||
likes: [],
|
||||
comments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return HttpResponse.json(newPost);
|
||||
}),
|
||||
|
||||
// Like a post
|
||||
http.put(`${API_URL}/api/posts/like/:id`, ({ params }) => {
|
||||
const post = mockPosts.find(p => p._id === params.id);
|
||||
|
||||
if (!post) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Post not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...post,
|
||||
likes: [...post.likes, 'user123'],
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// Events handlers
|
||||
const eventsHandlers = [
|
||||
// Get all events
|
||||
http.get(`${API_URL}/api/events`, () => {
|
||||
return HttpResponse.json(mockEvents);
|
||||
}),
|
||||
|
||||
// Create an event
|
||||
http.post(`${API_URL}/api/events`, async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const newEvent = {
|
||||
_id: `event${Date.now()}`,
|
||||
...body,
|
||||
organizer: 'user123',
|
||||
participants: [],
|
||||
};
|
||||
return HttpResponse.json(newEvent);
|
||||
}),
|
||||
|
||||
// RSVP to an event
|
||||
http.put(`${API_URL}/api/events/rsvp/:id`, ({ params }) => {
|
||||
const event = mockEvents.find(e => e._id === params.id);
|
||||
|
||||
if (!event) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Event not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json([...event.participants, 'user123']);
|
||||
}),
|
||||
];
|
||||
|
||||
// Rewards handlers
|
||||
const rewardsHandlers = [
|
||||
// Get all rewards
|
||||
http.get(`${API_URL}/api/rewards`, () => {
|
||||
return HttpResponse.json(mockRewards);
|
||||
}),
|
||||
|
||||
// Redeem a reward
|
||||
http.post(`${API_URL}/api/rewards/redeem/:id`, ({ params, request }) => {
|
||||
const reward = mockRewards.find(r => r._id === params.id);
|
||||
|
||||
if (!reward) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Reward not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (mockUser.points < reward.pointsCost) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Not enough points' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (reward.isPremium && !mockUser.isPremium) {
|
||||
return HttpResponse.json(
|
||||
{ msg: 'Premium reward not available' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({ msg: 'Reward redeemed successfully' });
|
||||
}),
|
||||
];
|
||||
|
||||
// Users handlers
|
||||
const usersHandlers = [
|
||||
// Get user profile
|
||||
http.get(`${API_URL}/api/users/:id`, ({ params }) => {
|
||||
if (params.id === 'user123') {
|
||||
return HttpResponse.json(mockUser);
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}),
|
||||
|
||||
// Update user profile
|
||||
http.put(`${API_URL}/api/users/:id`, async ({ params, request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (params.id === 'user123') {
|
||||
return HttpResponse.json({
|
||||
...mockUser,
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ msg: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
// Export all handlers
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...streetsHandlers,
|
||||
...tasksHandlers,
|
||||
...postsHandlers,
|
||||
...eventsHandlers,
|
||||
...rewardsHandlers,
|
||||
...usersHandlers,
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
// Setup MSW server with default handlers
|
||||
export const server = setupServer(...handlers);
|
||||
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,62 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// MSW (Mock Service Worker) for API mocking
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// Establish API mocking before all tests
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||
|
||||
// Reset any request handlers that we may add during the tests,
|
||||
// so they don't affect other tests
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
// Clean up after the tests are finished
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock window.matchMedia (used by some components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // Deprecated
|
||||
removeListener: jest.fn(), // Deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Suppress console errors during tests (except actual errors)
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].includes('Warning: ReactDOM.render') ||
|
||||
args[0].includes('Warning: useLayoutEffect') ||
|
||||
args[0].includes('Not implemented: HTMLFormElement.prototype.submit'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
Reference in New Issue
Block a user