refactor: convert frontend from submodule to true monorepo

Convert frontend from Git submodule to a regular monorepo directory for simplified development workflow.

Changes:
- Remove frontend submodule tracking (mode 160000 gitlink)
- Add all frontend source files directly to main repository
- Remove frontend/.git directory
- Update CLAUDE.md to clarify true monorepo structure
- Update Frontend Architecture documentation (React Router v6, Socket.IO, Leaflet, ErrorBoundary)

Benefits of Monorepo:
- Single git clone for entire project
- Unified commit history
- Simpler CI/CD pipeline
- Easier for new developers
- No submodule sync issues
- Atomic commits across frontend and backend

Frontend Files Added:
- All React components (MapView, ErrorBoundary, TaskList, SocialFeed, etc.)
- Context providers (AuthContext, SocketContext)
- Complete test suite with MSW
- Dependencies and configuration files

Branch Cleanup:
- Using 'main' as default branch (develop deleted)
- Frontend no longer has separate Git history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
William Valentin
2025-11-01 11:01:06 -07:00
parent 223dbb14b7
commit 2df5a303ed
38 changed files with 25312 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({
error: error,
errorInfo: errorInfo,
});
}
render() {
if (this.state.hasError) {
return (
<div className="container mt-5">
<div className="alert alert-danger" role="alert">
<h2 className="alert-heading">Something went wrong</h2>
<p>
We're sorry, but something unexpected happened. Please try
refreshing the page.
</p>
{process.env.NODE_ENV === "development" && this.state.error && (
<details className="mt-3" style={{ whiteSpace: "pre-wrap" }}>
<summary>Error details (development only)</summary>
<p className="mt-2">
<strong>Error:</strong> {this.state.error.toString()}
</p>
{this.state.errorInfo && (
<p>
<strong>Stack trace:</strong>
<br />
{this.state.errorInfo.componentStack}
</p>
)}
</details>
)}
<hr />
<button
className="btn btn-primary"
onClick={() => window.location.reload()}
>
Refresh Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+234
View File
@@ -0,0 +1,234 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { SocketContext } from "../context/SocketContext";
import { AuthContext } from "../context/AuthContext";
const Events = () => {
const { auth } = useContext(AuthContext);
const { socket, connected, on, off, joinEvent, leaveEvent } = useContext(SocketContext);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [joinedEvents, setJoinedEvents] = useState(new Set());
// Load events from API
const loadEvents = useCallback(async () => {
try {
setLoading(true);
const res = await axios.get("/api/events");
setEvents(res.data);
setError(null);
} catch (err) {
setError("Failed to load events. Please try again later.");
console.error("Error loading events:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadEvents();
}, [loadEvents]);
// Handle real-time event updates
useEffect(() => {
if (!socket || !connected) return;
const handleEventUpdate = (data) => {
console.log("Received event update:", data);
if (data.type === "participants_updated") {
// Update participant count for specific event
setEvents((prevEvents) =>
prevEvents.map((event) =>
event._id === data.eventId
? { ...event, participants: data.participants }
: event
)
);
} else if (data.type === "new_event") {
// Add new event to the list
setEvents((prevEvents) => [data.event, ...prevEvents]);
} else if (data.type === "event_updated") {
// Update existing event
setEvents((prevEvents) =>
prevEvents.map((event) =>
event._id === data.event._id ? data.event : event
)
);
} else if (data.type === "event_deleted") {
// Remove deleted event
setEvents((prevEvents) =>
prevEvents.filter((event) => event._id !== data.eventId)
);
}
};
// Subscribe to event updates
on("eventUpdate", handleEventUpdate);
// Cleanup on unmount
return () => {
off("eventUpdate", handleEventUpdate);
// Leave all joined event rooms
joinedEvents.forEach((eventId) => {
leaveEvent(eventId);
});
};
}, [socket, connected, on, off, joinedEvents, leaveEvent]);
// Join event room when viewing events
useEffect(() => {
if (!socket || !connected) return;
// Join each event room for real-time updates
events.forEach((event) => {
if (!joinedEvents.has(event._id)) {
joinEvent(event._id);
setJoinedEvents((prev) => new Set([...prev, event._id]));
}
});
}, [events, socket, connected, joinEvent, joinedEvents]);
const rsvp = async (id) => {
if (!auth.isAuthenticated) {
toast.warning("Please login to RSVP for events");
return;
}
try {
const token = localStorage.getItem("token");
const res = await axios.put(
`/api/events/rsvp/${id}`,
{},
{
headers: {
"x-auth-token": token,
},
}
);
setEvents(
events.map((event) =>
event._id === id ? { ...event, participants: res.data } : event
)
);
toast.success("Successfully RSVP'd to event!");
} catch (err) {
console.error("Error RSVPing to event:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to RSVP. Please try again.";
toast.error(errorMessage);
}
};
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading events...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
{error}
<button className="btn btn-primary ml-3" onClick={loadEvents}>
Retry
</button>
</div>
);
}
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h1>Events</h1>
{connected && (
<span className="badge badge-success">
<span className="mr-1">&#9679;</span> Live Updates
</span>
)}
</div>
{events.length === 0 ? (
<div className="alert alert-info">
No events available at the moment. Check back later!
</div>
) : (
<div className="row">
{events.map((event) => {
const eventDate = new Date(event.date);
const isUpcoming = eventDate > new Date();
return (
<div key={event._id} className="col-md-6 mb-4">
<div className="card h-100">
<div className="card-body">
<h5 className="card-title">{event.title}</h5>
<p className="card-text">{event.description}</p>
<div className="mb-2">
<small className="text-muted">
<strong>Date:</strong> {eventDate.toLocaleDateString()}{" "}
{eventDate.toLocaleTimeString()}
</small>
</div>
<div className="mb-2">
<small className="text-muted">
<strong>Location:</strong> {event.location}
</small>
</div>
{event.organizer && (
<div className="mb-2">
<small className="text-muted">
<strong>Organizer:</strong>{" "}
{event.organizer.name || event.organizer}
</small>
</div>
)}
<div className="mt-3">
<span
className={`badge badge-${
isUpcoming ? "success" : "secondary"
} mr-2`}
>
{isUpcoming ? "Upcoming" : "Past"}
</span>
<span className="badge badge-info">
{event.participants?.length || 0} Participants
</span>
</div>
{isUpcoming && auth.isAuthenticated && (
<button
className="btn btn-primary btn-sm mt-3 btn-block"
onClick={() => rsvp(event._id)}
>
RSVP
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default Events;
+101
View File
@@ -0,0 +1,101 @@
import React, { useState, useContext } from "react";
import { Navigate } from "react-router-dom";
import { AuthContext } from "../context/AuthContext";
const Login = () => {
const { auth, login } = useContext(AuthContext);
const [formData, setFormData] = useState({ email: "", password: "" });
const [loading, setLoading] = useState(false);
const { email, password } = formData;
const onChange = (e) =>
setFormData({ ...formData, [e.target.name]: e.target.value });
const onSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
} catch (error) {
console.error("Login submission error:", error);
} finally {
setLoading(false);
}
};
if (auth.loading) {
return (
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6 text-center">
<div className="spinner-border mt-5" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
</div>
</div>
);
}
if (auth.isAuthenticated) {
return <Navigate to="/map" replace />;
}
return (
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6">
<h1 className="text-center">Login</h1>
<form onSubmit={onSubmit}>
<div className="form-group">
<input
type="email"
name="email"
className="form-control"
value={email}
onChange={onChange}
placeholder="Email"
required
disabled={loading}
/>
</div>
<div className="form-group">
<input
type="password"
name="password"
className="form-control"
value={password}
onChange={onChange}
placeholder="Password"
required
disabled={loading}
/>
</div>
<button
type="submit"
className="btn btn-primary btn-block"
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Logging in...
</>
) : (
"Login"
)}
</button>
</form>
</div>
</div>
</div>
);
};
export default Login;
+374
View File
@@ -0,0 +1,374 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
// Fix for default marker icons in react-leaflet
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
// Custom marker icons for different street statuses
const createCustomIcon = (color) => {
return new L.Icon({
iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${color}.png`,
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
};
const availableIcon = createCustomIcon("green");
const adoptedIcon = createCustomIcon("blue");
const myStreetIcon = createCustomIcon("red");
// Component to handle map centering on user location
const LocationMarker = ({ userLocation, setUserLocation }) => {
const map = useMap();
useEffect(() => {
if (!userLocation) {
map
.locate()
.on("locationfound", (e) => {
setUserLocation(e.latlng);
map.flyTo(e.latlng, 13);
})
.on("locationerror", (e) => {
console.warn("Location access denied:", e.message);
});
}
}, [map, userLocation, setUserLocation]);
return userLocation === null ? null : (
<Marker position={userLocation}>
<Popup>You are here</Popup>
</Marker>
);
};
const MapView = () => {
const { auth } = useContext(AuthContext);
const [streets, setStreets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [userLocation, setUserLocation] = useState(null);
const [selectedStreet, setSelectedStreet] = useState(null);
const [adoptingStreetId, setAdoptingStreetId] = useState(null);
const mapRef = useRef();
// Default center (can be changed to your city's coordinates)
const defaultCenter = [40.7128, -74.006]; // New York City
useEffect(() => {
loadStreets();
}, []);
const loadStreets = async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get("/api/streets");
setStreets(res.data);
} catch (err) {
console.error("Error loading streets:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load streets. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const adoptStreet = async (id) => {
if (!auth.isAuthenticated) {
toast.warning("Please login to adopt a street");
return;
}
try {
setAdoptingStreetId(id);
const token = localStorage.getItem("token");
const res = await axios.put(
`/api/streets/adopt/${id}`,
{},
{
headers: {
"x-auth-token": token,
},
}
);
setStreets(
streets.map((street) => (street._id === id ? res.data : street))
);
setSelectedStreet(null);
toast.success("Street adopted successfully!");
} catch (err) {
console.error("Error adopting street:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to adopt street. Please try again.";
toast.error(errorMessage);
} finally {
setAdoptingStreetId(null);
}
};
const getMarkerIcon = (street) => {
if (
street.adoptedBy &&
auth.user &&
street.adoptedBy._id === auth.user._id
) {
return myStreetIcon;
}
if (street.status === "available") {
return availableIcon;
}
return adoptedIcon;
};
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading map data...</p>
</div>
);
}
if (error && streets.length === 0) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Map</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadStreets}>
Retry
</button>
</div>
);
}
return (
<div>
<h1 className="mb-3">Map View</h1>
<div className="mb-3">
<div className="d-flex align-items-center">
<span className="mr-3">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png"
alt="Available"
style={{ height: "20px" }}
/>
<small className="ml-1">Available</small>
</span>
<span className="mr-3">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-blue.png"
alt="Adopted"
style={{ height: "20px" }}
/>
<small className="ml-1">Adopted</small>
</span>
<span className="mr-3">
<img
src="https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png"
alt="My Street"
style={{ height: "20px" }}
/>
<small className="ml-1">My Streets</small>
</span>
</div>
</div>
<div style={{ height: "500px", width: "100%", marginBottom: "20px" }}>
<MapContainer
center={defaultCenter}
zoom={13}
style={{ height: "100%", width: "100%" }}
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<LocationMarker
userLocation={userLocation}
setUserLocation={setUserLocation}
/>
<MarkerClusterGroup>
{streets.map((street) => {
// Use street coordinates or generate random coordinates for demo
const position = street.coordinates
? [street.coordinates.lat, street.coordinates.lng]
: [
defaultCenter[0] + (Math.random() - 0.5) * 0.1,
defaultCenter[1] + (Math.random() - 0.5) * 0.1,
];
return (
<Marker
key={street._id}
position={position}
icon={getMarkerIcon(street)}
eventHandlers={{
click: () => {
setSelectedStreet(street);
},
}}
>
<Popup>
<div>
<h5>{street.name}</h5>
<p>
<strong>Status:</strong>{" "}
<span
className={`badge badge-${
street.status === "available"
? "success"
: "primary"
}`}
>
{street.status}
</span>
</p>
{street.adoptedBy && (
<p>
<strong>Adopted by:</strong> {street.adoptedBy.name}
</p>
)}
{street.description && (
<p>
<strong>Description:</strong> {street.description}
</p>
)}
{street.status === "available" && (
<button
className="btn btn-sm btn-success"
onClick={() => adoptStreet(street._id)}
disabled={adoptingStreetId === street._id}
>
{adoptingStreetId === street._id
? "Adopting..."
: "Adopt This Street"}
</button>
)}
</div>
</Popup>
</Marker>
);
})}
</MarkerClusterGroup>
</MapContainer>
</div>
{selectedStreet && (
<div className="card">
<div className="card-body">
<h5 className="card-title">{selectedStreet.name}</h5>
<p className="card-text">
<strong>Status:</strong>{" "}
<span
className={`badge badge-${
selectedStreet.status === "available" ? "success" : "primary"
}`}
>
{selectedStreet.status}
</span>
</p>
{selectedStreet.adoptedBy && (
<p className="card-text">
<strong>Adopted by:</strong> {selectedStreet.adoptedBy.name}
</p>
)}
{selectedStreet.description && (
<p className="card-text">
<strong>Description:</strong> {selectedStreet.description}
</p>
)}
{selectedStreet.status === "available" && (
<button
className="btn btn-success"
onClick={() => adoptStreet(selectedStreet._id)}
disabled={adoptingStreetId === selectedStreet._id}
>
{adoptingStreetId === selectedStreet._id
? "Adopting..."
: "Adopt This Street"}
</button>
)}
<button
className="btn btn-secondary ml-2"
onClick={() => setSelectedStreet(null)}
>
Close
</button>
</div>
</div>
)}
<div className="mt-3">
<h3>Street List</h3>
{streets.length === 0 ? (
<p className="text-muted">No streets available at the moment.</p>
) : (
<ul className="list-group">
{streets.map((street) => (
<li key={street._id} className="list-group-item">
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>{street.name}</strong>{" "}
<span
className={`badge badge-${
street.status === "available" ? "success" : "primary"
}`}
>
{street.status}
</span>
{street.adoptedBy && (
<small className="text-muted ml-2">
by {street.adoptedBy.name}
</small>
)}
</div>
{street.status === "available" && (
<button
className="btn btn-sm btn-success"
onClick={() => adoptStreet(street._id)}
disabled={adoptingStreetId === street._id}
>
{adoptingStreetId === street._id ? "Adopting..." : "Adopt"}
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default MapView;
+60
View File
@@ -0,0 +1,60 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
const Navbar = () => {
const { auth, logout } = useContext(AuthContext);
const authLinks = (
<ul className="navbar-nav ml-auto">
<li className="nav-item">
<Link className="nav-link" to="/map">Map</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/tasks">Tasks</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/feed">Feed</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/events">Events</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/rewards">Rewards</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/profile">Profile</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/premium">Premium</Link>
</li>
<li className="nav-item">
<a onClick={logout} href="#!" className="nav-link">Logout</a>
</li>
</ul>
);
const guestLinks = (
<ul className="navbar-nav ml-auto">
<li className="nav-item">
<Link className="nav-link" to="/register">Register</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
</ul>
);
return (
<nav className="navbar navbar-expand-sm navbar-dark bg-dark mb-4">
<div className="container">
<Link className="navbar-brand" to="/">Adopt-a-Street</Link>
<div className="collapse navbar-collapse">
{auth.isAuthenticated ? authLinks : guestLinks}
</div>
</div>
</nav>
);
};
export default Navbar;
+259
View File
@@ -0,0 +1,259 @@
import React, { useState, useContext } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
/**
* Premium component displays premium subscription information and allows users to subscribe
*/
const Premium = () => {
const { auth } = useContext(AuthContext);
const [subscribing, setSubscribing] = useState(false);
// Subscribe to premium
const subscribe = async () => {
if (!auth.isAuthenticated) {
toast.warning("Please login to subscribe to premium");
return;
}
if (auth.user?.isPremium) {
toast.info("You are already a premium member!");
return;
}
try {
setSubscribing(true);
const token = localStorage.getItem("token");
const res = await axios.post(
"/api/payments/subscribe",
{},
{
headers: {
"x-auth-token": token,
},
}
);
toast.success(
res.data.message || "Subscription successful! Welcome to premium!"
);
} catch (err) {
console.error("Error subscribing:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to subscribe. Please try again.";
toast.error(errorMessage);
} finally {
setSubscribing(false);
}
};
return (
<div>
<h1>Premium Subscription</h1>
{auth.user?.isPremium ? (
<div className="alert alert-success mb-4">
<h4 className="alert-heading">You are a Premium Member!</h4>
<p>
Thank you for your support! You have access to all premium features.
</p>
</div>
) : (
<div className="alert alert-info mb-4">
<h4 className="alert-heading">Upgrade to Premium</h4>
<p>
Unlock exclusive rewards and features by subscribing to our premium
plan.
</p>
</div>
)}
<div className="row">
<div className="col-md-8 offset-md-2">
<div className="card">
<div className="card-body">
<h3 className="card-title text-center mb-4">Premium Benefits</h3>
<ul className="list-group list-group-flush mb-4">
<li className="list-group-item">
<strong>Exclusive Rewards:</strong> Access to premium-only
rewards in the rewards catalog
</li>
<li className="list-group-item">
<strong>Priority Support:</strong> Get faster responses from
our support team
</li>
<li className="list-group-item">
<strong>Advanced Features:</strong> Unlock advanced analytics
and reporting tools
</li>
<li className="list-group-item">
<strong>Special Badge:</strong> Display your premium status
with a special badge
</li>
<li className="list-group-item">
<strong>Early Access:</strong> Be the first to try new
features and updates
</li>
<li className="list-group-item">
<strong>Ad-Free Experience:</strong> Enjoy the platform
without any advertisements
</li>
</ul>
<div className="text-center mb-4">
<h2 className="text-primary">$9.99/month</h2>
<p className="text-muted">Cancel anytime, no commitments</p>
</div>
{!auth.isAuthenticated ? (
<div className="alert alert-warning text-center">
<p className="mb-2">
Please log in to subscribe to premium.
</p>
<a href="/login" className="btn btn-primary">
Log In
</a>
</div>
) : auth.user?.isPremium ? (
<div className="text-center">
<button className="btn btn-success btn-lg" disabled>
Already Subscribed
</button>
</div>
) : (
<div className="text-center">
<button
className="btn btn-primary btn-lg"
onClick={subscribe}
disabled={subscribing}
>
{subscribing ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Processing...
</>
) : (
"Subscribe Now"
)}
</button>
<p className="text-muted mt-3">
<small>
Note: This is a mock subscription for development
purposes. No actual payment will be processed.
</small>
</p>
</div>
)}
</div>
</div>
</div>
</div>
<div className="row mt-5">
<div className="col-md-12">
<h3 className="text-center mb-4">Frequently Asked Questions</h3>
<div className="accordion" id="faqAccordion">
<div className="card">
<div className="card-header" id="faq1">
<h5 className="mb-0">
<button
className="btn btn-link"
type="button"
data-toggle="collapse"
data-target="#collapse1"
aria-expanded="true"
aria-controls="collapse1"
>
Can I cancel my subscription anytime?
</button>
</h5>
</div>
<div
id="collapse1"
className="collapse"
aria-labelledby="faq1"
data-parent="#faqAccordion"
>
<div className="card-body">
Yes! You can cancel your premium subscription at any time.
Your premium benefits will remain active until the end of your
current billing period.
</div>
</div>
</div>
<div className="card">
<div className="card-header" id="faq2">
<h5 className="mb-0">
<button
className="btn btn-link collapsed"
type="button"
data-toggle="collapse"
data-target="#collapse2"
aria-expanded="false"
aria-controls="collapse2"
>
What payment methods do you accept?
</button>
</h5>
</div>
<div
id="collapse2"
className="collapse"
aria-labelledby="faq2"
data-parent="#faqAccordion"
>
<div className="card-body">
We accept all major credit cards (Visa, MasterCard, American
Express) and PayPal. All payments are processed securely
through Stripe.
</div>
</div>
</div>
<div className="card">
<div className="card-header" id="faq3">
<h5 className="mb-0">
<button
className="btn btn-link collapsed"
type="button"
data-toggle="collapse"
data-target="#collapse3"
aria-expanded="false"
aria-controls="collapse3"
>
Will my points carry over if I upgrade?
</button>
</h5>
</div>
<div
id="collapse3"
className="collapse"
aria-labelledby="faq3"
data-parent="#faqAccordion"
>
<div className="card-body">
Absolutely! All your existing points, badges, and adopted
streets will remain unchanged when you upgrade to premium.
You'll simply gain access to additional premium features and
rewards.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Premium;
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
/**
* Profile component displays user profile information, adopted streets, and badges
*/
const Profile = () => {
const { auth } = useContext(AuthContext);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Load user profile from API
const loadProfile = useCallback(async () => {
if (!auth.user) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const userId = auth.user.id || auth.user._id;
const res = await axios.get(`/api/users/${userId}`);
setUser(res.data);
} catch (err) {
console.error("Error loading profile:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load profile. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, [auth.user]);
useEffect(() => {
loadProfile();
}, [loadProfile]);
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading profile...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Profile</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadProfile}>
Retry
</button>
</div>
);
}
if (!auth.isAuthenticated) {
return (
<div className="alert alert-warning m-3" role="alert">
<h4 className="alert-heading">Not Logged In</h4>
<p>Please log in to view your profile.</p>
</div>
);
}
if (!user) {
return (
<div className="alert alert-info m-3" role="alert">
<h4 className="alert-heading">No Profile Data</h4>
<p>Unable to load profile information.</p>
<button className="btn btn-primary" onClick={loadProfile}>
Retry
</button>
</div>
);
}
return (
<div>
<h1>{user.name}'s Profile</h1>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Profile Information</h5>
<p className="card-text">
<strong>Email:</strong> {user.email}
</p>
<p className="card-text">
<strong>Points:</strong>{" "}
<span className="badge badge-primary">{user.points || 0}</span>
</p>
{user.isPremium && (
<p className="card-text">
<span className="badge badge-warning">Premium Member</span>
</p>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Adopted Streets</h5>
{user.adoptedStreets && user.adoptedStreets.length > 0 ? (
<ul className="list-group">
{user.adoptedStreets.map((street) => (
<li key={street._id || street} className="list-group-item">
{street.name || street}
{street.status && (
<span
className={`badge badge-${
street.status === "available" ? "success" : "primary"
} ml-2`}
>
{street.status}
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-muted">
You haven't adopted any streets yet. Visit the map to adopt a
street!
</p>
)}
</div>
</div>
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Badges</h5>
{user.badges && user.badges.length > 0 ? (
<ul className="list-group">
{user.badges.map((badge, index) => (
<li key={index} className="list-group-item">
<span className="badge badge-success mr-2">
{badge}
</span>
</li>
))}
</ul>
) : (
<p className="text-muted">
No badges earned yet. Complete tasks and participate in events to
earn badges!
</p>
)}
</div>
</div>
{user.tasksCompleted !== undefined && (
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Statistics</h5>
<p className="card-text">
<strong>Tasks Completed:</strong>{" "}
<span className="badge badge-info">{user.tasksCompleted}</span>
</p>
{user.eventsAttended !== undefined && (
<p className="card-text">
<strong>Events Attended:</strong>{" "}
<span className="badge badge-info">{user.eventsAttended}</span>
</p>
)}
</div>
</div>
)}
</div>
);
};
export default Profile;
+117
View File
@@ -0,0 +1,117 @@
import React, { useState, useContext } from "react";
import { Navigate } from "react-router-dom";
import { AuthContext } from "../context/AuthContext";
const Register = () => {
const { auth, register } = useContext(AuthContext);
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
});
const [loading, setLoading] = useState(false);
const { name, email, password } = formData;
const onChange = (e) =>
setFormData({ ...formData, [e.target.name]: e.target.value });
const onSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await register(name, email, password);
} catch (error) {
console.error("Registration submission error:", error);
} finally {
setLoading(false);
}
};
if (auth.loading) {
return (
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6 text-center">
<div className="spinner-border mt-5" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
</div>
</div>
);
}
if (auth.isAuthenticated) {
return <Navigate to="/map" replace />;
}
return (
<div className="container">
<div className="row justify-content-center">
<div className="col-md-6">
<h1 className="text-center">Register</h1>
<form onSubmit={onSubmit}>
<div className="form-group">
<input
type="text"
name="name"
className="form-control"
value={name}
onChange={onChange}
placeholder="Name"
required
disabled={loading}
/>
</div>
<div className="form-group">
<input
type="email"
name="email"
className="form-control"
value={email}
onChange={onChange}
placeholder="Email"
required
disabled={loading}
/>
</div>
<div className="form-group">
<input
type="password"
name="password"
className="form-control"
value={password}
onChange={onChange}
placeholder="Password"
required
disabled={loading}
/>
</div>
<button
type="submit"
className="btn btn-primary btn-block"
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Registering...
</>
) : (
"Register"
)}
</button>
</form>
</div>
</div>
</div>
);
};
export default Register;
+218
View File
@@ -0,0 +1,218 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
/**
* Rewards component displays available rewards and allows users to redeem them
*/
const Rewards = () => {
const { auth } = useContext(AuthContext);
const [rewards, setRewards] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [redeemingRewardId, setRedeemingRewardId] = useState(null);
// Load rewards from API
const loadRewards = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get("/api/rewards");
setRewards(res.data);
} catch (err) {
console.error("Error loading rewards:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load rewards. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRewards();
}, [loadRewards]);
// Redeem a reward
const redeemReward = async (id, rewardName, cost) => {
if (!auth.isAuthenticated) {
toast.warning("Please login to redeem rewards");
return;
}
try {
setRedeemingRewardId(id);
const token = localStorage.getItem("token");
await axios.post(
`/api/rewards/redeem/${id}`,
{},
{
headers: {
"x-auth-token": token,
},
}
);
toast.success(
`Reward "${rewardName}" redeemed successfully! ${cost} points deducted.`
);
} catch (err) {
console.error("Error redeeming reward:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to redeem reward. Please try again.";
toast.error(errorMessage);
} finally {
setRedeemingRewardId(null);
}
};
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading rewards...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Rewards</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadRewards}>
Retry
</button>
</div>
);
}
return (
<div>
<h1>Rewards</h1>
{auth.user && (
<div className="alert alert-info mb-4">
<strong>Your Points:</strong>{" "}
<span className="badge badge-primary">{auth.user.points || 0}</span>
</div>
)}
{!auth.isAuthenticated && (
<div className="alert alert-warning mb-4">
Please log in to view and redeem rewards.
</div>
)}
{rewards.length === 0 ? (
<div className="alert alert-info">
No rewards available at the moment. Check back later!
</div>
) : (
<div className="row">
{rewards.map((reward) => {
const canAfford =
auth.isAuthenticated &&
auth.user &&
(auth.user.points || 0) >= reward.cost;
const canRedeem =
canAfford &&
(!reward.isPremium ||
(reward.isPremium && auth.user.isPremium));
return (
<div key={reward._id} className="col-md-6 col-lg-4 mb-4">
<div className="card h-100">
<div className="card-body d-flex flex-column">
<h5 className="card-title">{reward.name}</h5>
<p className="card-text flex-grow-1">
{reward.description}
</p>
<div className="mb-2">
<strong>Cost:</strong>{" "}
<span
className={`badge badge-${
canAfford ? "success" : "warning"
}`}
>
{reward.cost} points
</span>
</div>
{reward.isPremium && (
<div className="mb-2">
<span className="badge badge-warning">
Premium Only
</span>
</div>
)}
{reward.quantity !== undefined && (
<div className="mb-2">
<small className="text-muted">
Available: {reward.quantity}
</small>
</div>
)}
{auth.isAuthenticated ? (
<button
className={`btn btn-${
canRedeem ? "primary" : "secondary"
} btn-block mt-2`}
onClick={() =>
redeemReward(reward._id, reward.name, reward.cost)
}
disabled={
!canRedeem ||
redeemingRewardId === reward._id ||
(reward.quantity !== undefined && reward.quantity <= 0)
}
>
{redeemingRewardId === reward._id ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Redeeming...
</>
) : !canAfford ? (
"Insufficient Points"
) : reward.isPremium && !auth.user.isPremium ? (
"Premium Required"
) : reward.quantity !== undefined &&
reward.quantity <= 0 ? (
"Out of Stock"
) : (
"Redeem"
)}
</button>
) : (
<button className="btn btn-secondary btn-block mt-2" disabled>
Login to Redeem
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default Rewards;
+312
View File
@@ -0,0 +1,312 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
import { SocketContext } from "../context/SocketContext";
/**
* SocialFeed component displays community posts and allows creating new posts
* Includes real-time updates via Socket.IO
*/
const SocialFeed = () => {
const { auth } = useContext(AuthContext);
const { socket, connected, on, off } = useContext(SocketContext);
const [posts, setPosts] = useState([]);
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [likingPostId, setLikingPostId] = useState(null);
// Load posts from API
const loadPosts = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get("/api/posts");
setPosts(res.data);
} catch (err) {
console.error("Error loading posts:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load posts. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadPosts();
}, [loadPosts]);
// Handle real-time post updates via Socket.IO
useEffect(() => {
if (!socket || !connected) return;
const handleNewPost = (data) => {
console.log("Received new post:", data);
setPosts((prevPosts) => [data.post, ...prevPosts]);
toast.info("New post added to feed!");
};
const handlePostUpdate = (data) => {
console.log("Received post update:", data);
if (data.type === "post_liked") {
// Update specific post likes
setPosts((prevPosts) =>
prevPosts.map((post) =>
post._id === data.postId ? { ...post, likes: data.likes } : post
)
);
} else if (data.type === "post_updated") {
// Update existing post
setPosts((prevPosts) =>
prevPosts.map((post) =>
post._id === data.post._id ? data.post : post
)
);
} else if (data.type === "post_deleted") {
// Remove deleted post
setPosts((prevPosts) =>
prevPosts.filter((post) => post._id !== data.postId)
);
}
};
const handleNewComment = (data) => {
console.log("Received new comment:", data);
// Update post with new comment
setPosts((prevPosts) =>
prevPosts.map((post) =>
post._id === data.postId
? { ...post, comments: [...(post.comments || []), data.comment] }
: post
)
);
};
// Subscribe to post events
on("newPost", handleNewPost);
on("postUpdate", handlePostUpdate);
on("newComment", handleNewComment);
// Cleanup on unmount
return () => {
off("newPost", handleNewPost);
off("postUpdate", handlePostUpdate);
off("newComment", handleNewComment);
};
}, [socket, connected, on, off]);
// Like a post
const likePost = async (id) => {
if (!auth.isAuthenticated) {
toast.warning("Please login to like posts");
return;
}
try {
setLikingPostId(id);
const token = localStorage.getItem("token");
const res = await axios.put(
`/api/posts/like/${id}`,
{},
{
headers: {
"x-auth-token": token,
},
}
);
setPosts(
posts.map((post) =>
post._id === id ? { ...post, likes: res.data } : post
)
);
} catch (err) {
console.error("Error liking post:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to like post. Please try again.";
toast.error(errorMessage);
} finally {
setLikingPostId(null);
}
};
// Submit a new post
const onSubmit = async (e) => {
e.preventDefault();
if (!auth.isAuthenticated) {
toast.warning("Please login to create posts");
return;
}
if (!content.trim()) {
toast.warning("Post content cannot be empty");
return;
}
try {
setSubmitting(true);
const token = localStorage.getItem("token");
const res = await axios.post(
"/api/posts",
{ content },
{
headers: {
"x-auth-token": token,
},
}
);
setPosts([res.data, ...posts]);
setContent("");
toast.success("Post created successfully!");
} catch (err) {
console.error("Error creating post:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to create post. Please try again.";
toast.error(errorMessage);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading posts...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Posts</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadPosts}>
Retry
</button>
</div>
);
}
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h1>Social Feed</h1>
{connected && (
<span className="badge badge-success">
<span className="mr-1">&#9679;</span> Live Updates
</span>
)}
</div>
{auth.isAuthenticated && (
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Create a Post</h5>
<form onSubmit={onSubmit}>
<div className="form-group">
<textarea
className="form-control"
rows="3"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What's on your mind?"
required
disabled={submitting}
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={submitting || !content.trim()}
>
{submitting ? (
<>
<span
className="spinner-border spinner-border-sm mr-2"
role="status"
aria-hidden="true"
></span>
Posting...
</>
) : (
"Post"
)}
</button>
</form>
</div>
</div>
)}
{posts.length === 0 ? (
<div className="alert alert-info">
No posts yet. Be the first to share something!
</div>
) : (
<ul className="list-group">
{posts.map((post) => (
<li key={post._id} className="list-group-item">
<div className="mb-2">
<p className="mb-2">{post.content}</p>
<small className="text-muted">
By: <strong>{post.user?.name || "Unknown User"}</strong>
{post.createdAt && (
<span className="ml-2">
{new Date(post.createdAt).toLocaleDateString()}{" "}
{new Date(post.createdAt).toLocaleTimeString()}
</span>
)}
</small>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => likePost(post._id)}
disabled={!auth.isAuthenticated || likingPostId === post._id}
>
{likingPostId === post._id ? (
<>
<span
className="spinner-border spinner-border-sm mr-1"
role="status"
aria-hidden="true"
></span>
Liking...
</>
) : (
<>
Like ({post.likes?.length || 0})
</>
)}
</button>
{post.comments && post.comments.length > 0 && (
<div className="mt-2">
<small className="text-muted">
{post.comments.length} comment{post.comments.length !== 1 ? "s" : ""}
</small>
</div>
)}
</li>
))}
</ul>
)}
</div>
);
};
export default SocialFeed;
+213
View File
@@ -0,0 +1,213 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { AuthContext } from "../context/AuthContext";
import { SocketContext } from "../context/SocketContext";
/**
* TaskList component displays maintenance tasks and allows task completion
* Includes real-time updates via Socket.IO
*/
const TaskList = () => {
const { auth } = useContext(AuthContext);
const { socket, connected, on, off } = useContext(SocketContext);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [completingTaskId, setCompletingTaskId] = useState(null);
// Load tasks from API
const loadTasks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get("/api/tasks");
setTasks(res.data);
} catch (err) {
console.error("Error loading tasks:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to load tasks. Please try again later.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTasks();
}, [loadTasks]);
// Handle real-time task updates via Socket.IO
useEffect(() => {
if (!socket || !connected) return;
const handleTaskUpdate = (data) => {
console.log("Received task update:", data);
if (data.type === "task_completed") {
// Update specific task status
setTasks((prevTasks) =>
prevTasks.map((task) =>
task._id === data.taskId ? { ...task, status: "completed" } : task
)
);
toast.success("Task completed!");
} else if (data.type === "new_task") {
// Add new task to the list
setTasks((prevTasks) => [data.task, ...prevTasks]);
toast.info("New task available!");
} else if (data.type === "task_updated") {
// Update existing task
setTasks((prevTasks) =>
prevTasks.map((task) =>
task._id === data.task._id ? data.task : task
)
);
} else if (data.type === "task_deleted") {
// Remove deleted task
setTasks((prevTasks) =>
prevTasks.filter((task) => task._id !== data.taskId)
);
}
};
// Subscribe to task updates
on("taskUpdate", handleTaskUpdate);
// Cleanup on unmount
return () => {
off("taskUpdate", handleTaskUpdate);
};
}, [socket, connected, on, off]);
// Complete a task
const completeTask = async (id) => {
if (!auth.isAuthenticated) {
toast.warning("Please login to complete tasks");
return;
}
try {
setCompletingTaskId(id);
const token = localStorage.getItem("token");
const res = await axios.put(
`/api/tasks/${id}`,
{},
{
headers: {
"x-auth-token": token,
},
}
);
setTasks(tasks.map((task) => (task._id === id ? res.data : task)));
toast.success("Task completed successfully!");
} catch (err) {
console.error("Error completing task:", err);
const errorMessage =
err.response?.data?.msg ||
err.response?.data?.message ||
"Failed to complete task. Please try again.";
toast.error(errorMessage);
} finally {
setCompletingTaskId(null);
}
};
if (loading) {
return (
<div className="text-center mt-5">
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
<p>Loading tasks...</p>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger m-3" role="alert">
<h4 className="alert-heading">Error Loading Tasks</h4>
<p>{error}</p>
<hr />
<button className="btn btn-primary" onClick={loadTasks}>
Retry
</button>
</div>
);
}
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h1>Task List</h1>
{connected && (
<span className="badge badge-success">
<span className="mr-1">&#9679;</span> Live Updates
</span>
)}
</div>
{tasks.length === 0 ? (
<div className="alert alert-info">
No tasks available at the moment. Check back later!
</div>
) : (
<ul className="list-group">
{tasks.map((task) => (
<li
key={task._id}
className="list-group-item d-flex justify-content-between align-items-center"
>
<div>
<strong>{task.description}</strong>
<br />
<span
className={`badge badge-${
task.status === "pending" ? "warning" : "success"
} mr-2`}
>
{task.status}
</span>
{task.street && (
<small className="text-muted">Street: {task.street.name || task.street}</small>
)}
{task.assignedTo && (
<small className="text-muted ml-2">
Assigned to: {task.assignedTo.name || task.assignedTo}
</small>
)}
</div>
{task.status === "pending" && auth.isAuthenticated && (
<button
className="btn btn-sm btn-success"
onClick={() => completeTask(task._id)}
disabled={completingTaskId === task._id}
>
{completingTaskId === task._id ? (
<>
<span
className="spinner-border spinner-border-sm mr-1"
role="status"
aria-hidden="true"
></span>
Completing...
</>
) : (
"Complete"
)}
</button>
)}
</li>
))}
</ul>
)}
</div>
);
};
export default TaskList;
@@ -0,0 +1,184 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ErrorBoundary from '../ErrorBoundary';
// Component that throws an error
const ThrowError = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>No Error</div>;
};
describe('ErrorBoundary Component', () => {
beforeEach(() => {
// Suppress console errors during error boundary tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
console.error.mockRestore();
});
describe('Normal Rendering', () => {
it('should render children when no error occurs', () => {
render(
<ErrorBoundary>
<div>Child Component</div>
</ErrorBoundary>
);
expect(screen.getByText('Child Component')).toBeInTheDocument();
});
it('should render multiple children when no error occurs', () => {
render(
<ErrorBoundary>
<div>Child 1</div>
<div>Child 2</div>
</ErrorBoundary>
);
expect(screen.getByText('Child 1')).toBeInTheDocument();
expect(screen.getByText('Child 2')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should catch errors and display fallback UI', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
});
it('should display error message in fallback UI', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
expect(screen.getByText(/please try refreshing/i)).toBeInTheDocument();
});
it('should not render children when error occurs', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.queryByText('No Error')).not.toBeInTheDocument();
});
it('should catch errors in nested components', () => {
const NestedComponent = () => {
return (
<div>
<ThrowError shouldThrow={true} />
</div>
);
};
render(
<ErrorBoundary>
<NestedComponent />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
});
});
describe('Refresh Button', () => {
it('should display a refresh button in error state', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
expect(refreshButton).toBeInTheDocument();
});
it('should reload page when refresh button is clicked', () => {
// Mock window.location.reload
delete window.location;
window.location = { reload: jest.fn() };
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
const refreshButton = screen.getByRole('button', { name: /refresh page/i });
refreshButton.click();
expect(window.location.reload).toHaveBeenCalled();
});
});
describe('State Updates', () => {
it('should update state when error is caught', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('No Error')).toBeInTheDocument();
rerender(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
});
});
describe('Error Info', () => {
it('should not crash when rendering error boundary', () => {
expect(() => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
}).not.toThrow();
});
it('should log error to console', () => {
const consoleErrorSpy = jest.spyOn(console, 'error');
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(consoleErrorSpy).toHaveBeenCalled();
});
});
describe('Container Styling', () => {
it('should render error container with proper classes', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
const container = screen.getByRole('heading', { name: /something went wrong/i }).closest('div');
expect(container).toBeInTheDocument();
});
});
});
@@ -0,0 +1,258 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Login from '../Login';
import { AuthContext } from '../../context/AuthContext';
// Mock useNavigate
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Navigate: ({ to }) => {
mockedNavigate(to);
return null;
},
}));
describe('Login Component', () => {
const mockLogin = jest.fn();
const mockAuthContext = {
auth: {
isAuthenticated: false,
loading: false,
user: null,
},
login: mockLogin,
};
const renderLogin = (contextValue = mockAuthContext) => {
return render(
<BrowserRouter>
<AuthContext.Provider value={contextValue}>
<Login />
</AuthContext.Provider>
</BrowserRouter>
);
};
beforeEach(() => {
mockLogin.mockClear();
mockedNavigate.mockClear();
});
describe('Rendering', () => {
it('should render login form', () => {
renderLogin();
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('should render email input field', () => {
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
expect(emailInput).toBeInTheDocument();
expect(emailInput).toHaveAttribute('type', 'email');
expect(emailInput).toHaveAttribute('required');
});
it('should render password input field', () => {
renderLogin();
const passwordInput = screen.getByPlaceholderText(/password/i);
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveAttribute('type', 'password');
expect(passwordInput).toHaveAttribute('required');
});
});
describe('Form Validation', () => {
it('should update email field on change', () => {
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
expect(emailInput).toHaveValue('test@example.com');
});
it('should update password field on change', () => {
renderLogin();
const passwordInput = screen.getByPlaceholderText(/password/i);
fireEvent.change(passwordInput, { target: { value: 'password123' } });
expect(passwordInput).toHaveValue('password123');
});
it('should have required fields', () => {
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
expect(emailInput).toBeRequired();
expect(passwordInput).toBeRequired();
});
});
describe('Form Submission', () => {
it('should call login function on form submit', async () => {
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
});
});
it('should disable form fields during submission', async () => {
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(emailInput).toBeDisabled();
expect(passwordInput).toBeDisabled();
expect(submitButton).toBeDisabled();
});
});
it('should show loading state during submission', async () => {
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/logging in/i)).toBeInTheDocument();
});
});
it('should handle login errors gracefully', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockLogin.mockRejectedValue(new Error('Login failed'));
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'wrong' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
});
consoleErrorSpy.mockRestore();
});
});
describe('Authentication State', () => {
it('should redirect to /map when already authenticated', () => {
const authenticatedContext = {
auth: {
isAuthenticated: true,
loading: false,
user: { name: 'Test User' },
},
login: mockLogin,
};
renderLogin(authenticatedContext);
expect(mockedNavigate).toHaveBeenCalledWith('/map');
});
it('should show loading spinner when auth is loading', () => {
const loadingContext = {
auth: {
isAuthenticated: false,
loading: true,
user: null,
},
login: mockLogin,
};
renderLogin(loadingContext);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('should not show form when auth is loading', () => {
const loadingContext = {
auth: {
isAuthenticated: false,
loading: true,
user: null,
},
login: mockLogin,
};
renderLogin(loadingContext);
expect(screen.queryByPlaceholderText(/email/i)).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(/password/i)).not.toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have accessible form elements', () => {
renderLogin();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/password/i);
expect(emailInput).toHaveAttribute('name', 'email');
expect(passwordInput).toHaveAttribute('name', 'password');
});
it('should have accessible button', () => {
renderLogin();
const submitButton = screen.getByRole('button', { name: /login/i });
expect(submitButton).toHaveAttribute('type', 'submit');
});
});
describe('Empty Form Submission', () => {
it('should not submit with empty fields', () => {
renderLogin();
const submitButton = screen.getByRole('button', { name: /login/i });
fireEvent.click(submitButton);
expect(mockLogin).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,262 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Register from '../Register';
import { AuthContext } from '../../context/AuthContext';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Navigate: ({ to }) => {
mockedNavigate(to);
return null;
},
}));
describe('Register Component', () => {
const mockRegister = jest.fn();
const mockAuthContext = {
auth: {
isAuthenticated: false,
loading: false,
user: null,
},
register: mockRegister,
};
const renderRegister = (contextValue = mockAuthContext) => {
return render(
<BrowserRouter>
<AuthContext.Provider value={contextValue}>
<Register />
</AuthContext.Provider>
</BrowserRouter>
);
};
beforeEach(() => {
mockRegister.mockClear();
mockedNavigate.mockClear();
});
describe('Rendering', () => {
it('should render registration form', () => {
renderRegister();
expect(screen.getByRole('heading', { name: /register/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/^password$/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
});
it('should render all required input fields', () => {
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
expect(nameInput).toBeRequired();
expect(emailInput).toBeRequired();
expect(passwordInput).toBeRequired();
expect(confirmPasswordInput).toBeRequired();
});
});
describe('Form Input Changes', () => {
it('should update name field on change', () => {
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
expect(nameInput).toHaveValue('John Doe');
});
it('should update email field on change', () => {
renderRegister();
const emailInput = screen.getByPlaceholderText(/email/i);
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
expect(emailInput).toHaveValue('john@example.com');
});
it('should update password field on change', () => {
renderRegister();
const passwordInput = screen.getByPlaceholderText(/^password$/i);
fireEvent.change(passwordInput, { target: { value: 'password123' } });
expect(passwordInput).toHaveValue('password123');
});
it('should update confirm password field on change', () => {
renderRegister();
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
expect(confirmPasswordInput).toHaveValue('password123');
});
});
describe('Form Submission', () => {
it('should call register function with valid data', async () => {
mockRegister.mockResolvedValue({ success: true });
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
const submitButton = screen.getByRole('button', { name: /register/i });
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith('John Doe', 'john@example.com', 'password123');
});
});
it('should disable form during submission', async () => {
mockRegister.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
const submitButton = screen.getByRole('button', { name: /register/i });
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(nameInput).toBeDisabled();
expect(emailInput).toBeDisabled();
expect(passwordInput).toBeDisabled();
expect(confirmPasswordInput).toBeDisabled();
expect(submitButton).toBeDisabled();
});
});
});
describe('Password Validation', () => {
it('should validate minimum password length', async () => {
renderRegister();
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
fireEvent.change(passwordInput, { target: { value: '12345' } });
fireEvent.change(confirmPasswordInput, { target: { value: '12345' } });
// Password should have minLength attribute
expect(passwordInput).toHaveAttribute('minLength');
});
it('should show error when passwords do not match', async () => {
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
const submitButton = screen.getByRole('button', { name: /register/i });
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'different' } });
fireEvent.click(submitButton);
// Should not call register if passwords don't match
expect(mockRegister).not.toHaveBeenCalled();
});
});
describe('Authentication State', () => {
it('should redirect to /map when already authenticated', () => {
const authenticatedContext = {
auth: {
isAuthenticated: true,
loading: false,
user: { name: 'Test User' },
},
register: mockRegister,
};
renderRegister(authenticatedContext);
expect(mockedNavigate).toHaveBeenCalledWith('/map');
});
it('should show loading spinner when auth is loading', () => {
const loadingContext = {
auth: {
isAuthenticated: false,
loading: true,
user: null,
},
register: mockRegister,
};
renderRegister(loadingContext);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Field Types', () => {
it('should have correct input types', () => {
renderRegister();
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
expect(emailInput).toHaveAttribute('type', 'email');
expect(passwordInput).toHaveAttribute('type', 'password');
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
});
});
describe('Error Handling', () => {
it('should handle registration errors', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockRegister.mockRejectedValue(new Error('Registration failed'));
renderRegister();
const nameInput = screen.getByPlaceholderText(/name/i);
const emailInput = screen.getByPlaceholderText(/email/i);
const passwordInput = screen.getByPlaceholderText(/^password$/i);
const confirmPasswordInput = screen.getByPlaceholderText(/confirm password/i);
const submitButton = screen.getByRole('button', { name: /register/i });
fireEvent.change(nameInput, { target: { value: 'John Doe' } });
fireEvent.change(emailInput, { target: { value: 'john@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
});
consoleErrorSpy.mockRestore();
});
});
});