feat: implement comprehensive gamification, analytics, and leaderboard system
This commit adds a complete gamification system with analytics dashboards, leaderboards, and enhanced badge tracking functionality. Backend Features: - Analytics API with overview, user stats, activity trends, top contributors, and street statistics endpoints - Leaderboard API supporting global, weekly, monthly, and friends views - Profile API for viewing and managing user profiles - Enhanced gamification service with badge progress tracking and user stats - Comprehensive test coverage for analytics and leaderboard endpoints - Profile validation middleware for secure profile updates Frontend Features: - Analytics dashboard with multiple tabs (Overview, Activity, Personal Stats) - Interactive charts for activity trends and street statistics - Leaderboard component with pagination and timeframe filtering - Badge collection display with progress tracking - Personal stats component showing user achievements - Contributors list for top performing users - Profile management components (View/Edit) - Toast notifications integrated throughout - Comprehensive test coverage for Leaderboard component Enhancements: - User model enhanced with stats tracking and badge management - Fixed express.Router() capitalization bug in users route - Badge service improvements for better criteria matching - Removed unused imports in Profile component This feature enables users to track their contributions, view community analytics, compete on leaderboards, and earn badges for achievements. 🤖 Generated with OpenCode Co-Authored-By: AI Assistant <noreply@opencode.ai>
This commit is contained in:
@@ -15,7 +15,9 @@ import SocialFeed from "./components/SocialFeed";
|
||||
import Profile from "./components/Profile";
|
||||
import Events from "./components/Events";
|
||||
import Rewards from "./components/Rewards";
|
||||
import Leaderboard from "./components/Leaderboard";
|
||||
import Premium from "./components/Premium";
|
||||
import Analytics from "./components/Analytics";
|
||||
import Navbar from "./components/Navbar";
|
||||
import PrivateRoute from "./components/PrivateRoute";
|
||||
|
||||
@@ -36,7 +38,9 @@ function App() {
|
||||
<Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
|
||||
<Route path="/events" element={<PrivateRoute><Events /></PrivateRoute>} />
|
||||
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
|
||||
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
|
||||
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
|
||||
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
|
||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import Leaderboard from "../components/Leaderboard";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
// Mock axios
|
||||
jest.mock("axios");
|
||||
|
||||
// Mock react-toastify
|
||||
jest.mock("react-toastify", () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock leaderboard data
|
||||
const mockLeaderboardData = [
|
||||
{
|
||||
userId: "user1",
|
||||
username: "TopUser",
|
||||
email: "topuser@example.com",
|
||||
points: 1000,
|
||||
streetsAdopted: 5,
|
||||
tasksCompleted: 20,
|
||||
badges: [
|
||||
{ name: "Beginner", icon: "🏅" },
|
||||
{ name: "Intermediate", icon: "🏆" },
|
||||
],
|
||||
},
|
||||
{
|
||||
userId: "user2",
|
||||
username: "SecondUser",
|
||||
email: "second@example.com",
|
||||
points: 800,
|
||||
streetsAdopted: 4,
|
||||
tasksCompleted: 15,
|
||||
badges: [{ name: "Beginner", icon: "🏅" }],
|
||||
},
|
||||
{
|
||||
userId: "user3",
|
||||
username: "ThirdUser",
|
||||
email: "third@example.com",
|
||||
points: 600,
|
||||
streetsAdopted: 3,
|
||||
tasksCompleted: 10,
|
||||
badges: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockStats = {
|
||||
totalUsers: 100,
|
||||
totalPoints: 50000,
|
||||
averagePoints: 500,
|
||||
maxPoints: 1000,
|
||||
minPoints: 0,
|
||||
};
|
||||
|
||||
describe("Leaderboard Component", () => {
|
||||
const mockAuthContext = {
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
_id: "user1",
|
||||
username: "TopUser",
|
||||
points: 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderLeaderboard = (authContext = mockAuthContext) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<AuthContext.Provider value={authContext}>
|
||||
<Leaderboard />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.setItem("token", "mock-token");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Initial Loading", () => {
|
||||
it("should display loading spinner on initial load", () => {
|
||||
axios.get.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
renderLeaderboard();
|
||||
|
||||
expect(screen.getByText(/loading leaderboard/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should load global leaderboard by default", async () => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/global")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
if (url.includes("/api/leaderboard/stats")) {
|
||||
return Promise.resolve({ data: mockStats });
|
||||
}
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/leaderboard/global"),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should load leaderboard stats", async () => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/global")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
if (url.includes("/api/leaderboard/stats")) {
|
||||
return Promise.resolve({ data: mockStats });
|
||||
}
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/total users:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/50,000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tab Navigation", () => {
|
||||
beforeEach(() => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch to weekly leaderboard when clicking weekly tab", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const weeklyTab = screen.getByRole("button", { name: /this week/i });
|
||||
fireEvent.click(weeklyTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/leaderboard/weekly"),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch to monthly leaderboard when clicking monthly tab", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const monthlyTab = screen.getByRole("button", { name: /this month/i });
|
||||
fireEvent.click(monthlyTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/leaderboard/monthly"),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch to friends leaderboard when authenticated", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
||||
fireEvent.click(friendsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/leaderboard/friends"),
|
||||
expect.objectContaining({
|
||||
headers: { "x-auth-token": "mock-token" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show warning when trying to access friends tab without authentication", async () => {
|
||||
const unauthContext = {
|
||||
auth: {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
},
|
||||
};
|
||||
|
||||
axios.get.mockResolvedValue({ data: mockLeaderboardData });
|
||||
renderLeaderboard(unauthContext);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
||||
fireEvent.click(friendsTab);
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
"Please login to view friends leaderboard"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Display", () => {
|
||||
beforeEach(() => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
});
|
||||
|
||||
it("should display all users in leaderboard", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
expect(screen.getByText("SecondUser")).toBeInTheDocument();
|
||||
expect(screen.getByText("ThirdUser")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should highlight current user", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("You")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user points", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1,000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display user statistics", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check for streets and tasks counts
|
||||
const statsElements = screen.getAllByText(/5|20/);
|
||||
expect(statsElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display current user points in alert", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/your points:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
beforeEach(() => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable previous button on first page", async () => {
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const previousButton = screen.getByRole("button", { name: /previous/i });
|
||||
expect(previousButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable next button when there are more results", async () => {
|
||||
// Mock 50 results to trigger hasMore
|
||||
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
|
||||
userId: `user${i}`,
|
||||
username: `User${i}`,
|
||||
points: 1000 - i * 10,
|
||||
streetsAdopted: 1,
|
||||
tasksCompleted: 1,
|
||||
badges: [],
|
||||
}));
|
||||
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: largeDataset });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("User0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should load next page when clicking next button", async () => {
|
||||
const largeDataset = Array.from({ length: 50 }, (_, i) => ({
|
||||
userId: `user${i}`,
|
||||
username: `User${i}`,
|
||||
points: 1000 - i * 10,
|
||||
streetsAdopted: 1,
|
||||
tasksCompleted: 1,
|
||||
badges: [],
|
||||
}));
|
||||
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: largeDataset });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("User0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining("offset=50"),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should display error message when API fails", async () => {
|
||||
axios.get.mockRejectedValue({
|
||||
response: { data: { msg: "Server error" } },
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error loading leaderboard/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Server error");
|
||||
});
|
||||
|
||||
it("should show retry button on error", async () => {
|
||||
axios.get.mockRejectedValue({
|
||||
response: { data: { msg: "Server error" } },
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry loading when clicking retry button", async () => {
|
||||
axios.get
|
||||
.mockRejectedValueOnce({
|
||||
response: { data: { msg: "Server error" } },
|
||||
})
|
||||
.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: /retry/i });
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
it("should display message when leaderboard is empty", async () => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/no users to display yet/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display friends-specific message when friends leaderboard is empty", async () => {
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes("/api/leaderboard/friends")) {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
if (url.includes("/api/leaderboard/")) {
|
||||
return Promise.resolve({ data: mockLeaderboardData });
|
||||
}
|
||||
return Promise.resolve({ data: mockStats });
|
||||
});
|
||||
|
||||
renderLeaderboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TopUser")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const friendsTab = screen.getByRole("button", { name: /friends/i });
|
||||
fireEvent.click(friendsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/no friends to display/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
const ActivityChart = ({ data, groupBy }) => {
|
||||
// Format period labels based on groupBy
|
||||
const formatPeriod = (period) => {
|
||||
if (!period) return "";
|
||||
|
||||
if (groupBy === "month") {
|
||||
const [year, month] = period.split("-");
|
||||
const date = new Date(year, parseInt(month) - 1);
|
||||
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
} else if (groupBy === "week") {
|
||||
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
} else {
|
||||
return new Date(period).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
period: formatPeriod(item.period),
|
||||
Tasks: item.tasks,
|
||||
Posts: item.posts,
|
||||
Events: item.events,
|
||||
"Streets Adopted": item.streetsAdopted,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h6>Activity Trends</h6>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="Tasks" stroke="#0d6efd" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Posts" stroke="#198754" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="Events" stroke="#ffc107" strokeWidth={2} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="Streets Adopted"
|
||||
stroke="#dc3545"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6>Activity Comparison</h6>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="Tasks" fill="#0d6efd" />
|
||||
<Bar dataKey="Posts" fill="#198754" />
|
||||
<Bar dataKey="Events" fill="#ffc107" />
|
||||
<Bar dataKey="Streets Adopted" fill="#dc3545" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityChart;
|
||||
@@ -0,0 +1,182 @@
|
||||
.analytics-container {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.analytics-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.analytics-header h2 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.analytics-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.analytics-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analytics-tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.analytics-tab:hover {
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.analytics-tab.active {
|
||||
color: #28a745;
|
||||
border-bottom-color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeframe-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeframe-btn {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timeframe-btn:hover {
|
||||
border-color: #28a745;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.timeframe-btn.active {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #28a745;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.charts-section h3 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analytics-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.analytics-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.analytics-tab {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import ActivityChart from "./ActivityChart";
|
||||
import ContributorsList from "./ContributorsList";
|
||||
import StreetStatsChart from "./StreetStatsChart";
|
||||
import PersonalStats from "./PersonalStats";
|
||||
import "./Analytics.css";
|
||||
|
||||
const Analytics = () => {
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [activity, setActivity] = useState(null);
|
||||
const [contributors, setContributors] = useState([]);
|
||||
const [streetStats, setStreetStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [timeframe, setTimeframe] = useState("30d");
|
||||
const [groupBy, setGroupBy] = useState("day");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
const fetchAnalyticsData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const config = {
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
};
|
||||
|
||||
const [overviewRes, activityRes, contributorsRes, streetStatsRes] = await Promise.all([
|
||||
axios.get(`/api/analytics/overview?timeframe=${timeframe}`, config),
|
||||
axios.get(`/api/analytics/activity?timeframe=${timeframe}&groupBy=${groupBy}`, config),
|
||||
axios.get(`/api/analytics/top-contributors?limit=10&timeframe=${timeframe}`, config),
|
||||
axios.get(`/api/analytics/street-stats?timeframe=${timeframe}`, config),
|
||||
]);
|
||||
|
||||
setOverview(overviewRes.data.overview);
|
||||
setActivity(activityRes.data);
|
||||
setContributors(contributorsRes.data.contributors);
|
||||
setStreetStats(streetStatsRes.data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching analytics:", err);
|
||||
setError(err.response?.data?.msg || "Failed to load analytics data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyticsData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeframe, groupBy]);
|
||||
|
||||
const handleTimeframeChange = (e) => {
|
||||
setTimeframe(e.target.value);
|
||||
};
|
||||
|
||||
const handleGroupByChange = (e) => {
|
||||
setGroupBy(e.target.value);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="analytics-container">
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="mt-3">Loading analytics...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="analytics-container">
|
||||
<div className="alert alert-danger mt-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-container">
|
||||
<div className="analytics-header">
|
||||
<h1>Analytics Dashboard</h1>
|
||||
<div className="analytics-controls">
|
||||
<div className="form-group">
|
||||
<label htmlFor="timeframe">Timeframe:</label>
|
||||
<select
|
||||
id="timeframe"
|
||||
className="form-select"
|
||||
value={timeframe}
|
||||
onChange={handleTimeframeChange}
|
||||
>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 30 Days</option>
|
||||
<option value="90d">Last 90 Days</option>
|
||||
<option value="all">All Time</option>
|
||||
</select>
|
||||
</div>
|
||||
{activeTab === "activity" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="groupBy">Group By:</label>
|
||||
<select
|
||||
id="groupBy"
|
||||
className="form-select"
|
||||
value={groupBy}
|
||||
onChange={handleGroupByChange}
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="nav nav-tabs mb-4">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "activity" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("activity")}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "personal" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("personal")}
|
||||
>
|
||||
My Stats
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{activeTab === "overview" && overview && (
|
||||
<div className="overview-tab">
|
||||
<div className="row">
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-primary">
|
||||
<i className="fas fa-users"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.totalUsers}</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-success">
|
||||
<i className="fas fa-road"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.adoptedStreets}</h3>
|
||||
<p>Streets Adopted</p>
|
||||
<small className="text-muted">
|
||||
{overview.availableStreets} available
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-info">
|
||||
<i className="fas fa-tasks"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.completedTasks}</h3>
|
||||
<p>Tasks Completed</p>
|
||||
<small className="text-muted">
|
||||
{overview.pendingTasks} pending
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-warning">
|
||||
<i className="fas fa-calendar"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.activeEvents}</h3>
|
||||
<p>Active Events</p>
|
||||
<small className="text-muted">
|
||||
{overview.completedEvents} completed
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt-4">
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-secondary">
|
||||
<i className="fas fa-comments"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.totalPosts}</h3>
|
||||
<p>Total Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-danger">
|
||||
<i className="fas fa-star"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.totalPoints.toLocaleString()}</h3>
|
||||
<p>Total Points</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-3 mb-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-dark">
|
||||
<i className="fas fa-chart-line"></i>
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>{overview.averagePointsPerUser}</h3>
|
||||
<p>Avg Points/User</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt-4">
|
||||
<div className="col-lg-8 mb-4">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Street Statistics</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{streetStats && <StreetStatsChart data={streetStats} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4 mb-4">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Top Contributors</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ContributorsList contributors={contributors} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "activity" && activity && (
|
||||
<div className="activity-tab">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Activity Over Time</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ActivityChart data={activity.activity} groupBy={groupBy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mt-4">
|
||||
<div className="col-md-3">
|
||||
<div className="stat-card small">
|
||||
<h4>{activity.summary.totalTasks}</h4>
|
||||
<p>Total Tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="stat-card small">
|
||||
<h4>{activity.summary.totalPosts}</h4>
|
||||
<p>Total Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="stat-card small">
|
||||
<h4>{activity.summary.totalEvents}</h4>
|
||||
<p>Total Events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="stat-card small">
|
||||
<h4>{activity.summary.totalStreetsAdopted}</h4>
|
||||
<p>Streets Adopted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "personal" && (
|
||||
<div className="personal-tab">
|
||||
<PersonalStats timeframe={timeframe} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import BadgeDisplay from "./BadgeDisplay";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
|
||||
/**
|
||||
* BadgeCollection component - displays all available badges with filter/sort options
|
||||
*/
|
||||
const BadgeCollection = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [badges, setBadges] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filter, setFilter] = useState("all"); // all, earned, locked
|
||||
const [sortBy, setSortBy] = useState("rarity"); // rarity, name
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBadges = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch all badges and user's progress
|
||||
const [allBadgesRes, progressRes] = await Promise.all([
|
||||
axios.get("/api/badges"),
|
||||
axios.get("/api/badges/progress"),
|
||||
]);
|
||||
|
||||
// Merge badge data with progress data
|
||||
const badgesWithProgress = allBadgesRes.data.map((badge) => {
|
||||
const progress = progressRes.data.find((p) => p._id === badge._id);
|
||||
return {
|
||||
...badge,
|
||||
isEarned: progress?.isEarned || false,
|
||||
progress: progress?.progress || 0,
|
||||
threshold: progress?.threshold || badge.criteria?.threshold || 0,
|
||||
};
|
||||
});
|
||||
|
||||
setBadges(badgesWithProgress);
|
||||
} catch (err) {
|
||||
console.error("Error fetching badges:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load badges";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
fetchBadges();
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Filter badges based on selected filter
|
||||
const getFilteredBadges = () => {
|
||||
let filtered = [...badges];
|
||||
|
||||
if (filter === "earned") {
|
||||
filtered = filtered.filter((badge) => badge.isEarned);
|
||||
} else if (filter === "locked") {
|
||||
filtered = filtered.filter((badge) => !badge.isEarned);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Sort badges based on selected sort option
|
||||
const getSortedBadges = () => {
|
||||
const filtered = getFilteredBadges();
|
||||
|
||||
if (sortBy === "rarity") {
|
||||
const rarityOrder = { legendary: 0, epic: 1, rare: 2, common: 3 };
|
||||
return filtered.sort(
|
||||
(a, b) => rarityOrder[a.rarity] - rarityOrder[b.rarity]
|
||||
);
|
||||
} else if (sortBy === "name") {
|
||||
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const sortedBadges = getSortedBadges();
|
||||
const earnedCount = badges.filter((b) => b.isEarned).length;
|
||||
const totalCount = badges.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading badges...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Badges</h4>
|
||||
<p>{error}</p>
|
||||
</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 badges.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge-collection">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Badge Collection</h2>
|
||||
<div>
|
||||
<span className="badge badge-primary badge-lg">
|
||||
{earnedCount} / {totalCount} Earned
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="filterSelect" className="form-label">
|
||||
<strong>Filter:</strong>
|
||||
</label>
|
||||
<select
|
||||
id="filterSelect"
|
||||
className="form-control"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Badges</option>
|
||||
<option value="earned">Earned Only</option>
|
||||
<option value="locked">Locked Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="sortSelect" className="form-label">
|
||||
<strong>Sort By:</strong>
|
||||
</label>
|
||||
<select
|
||||
id="sortSelect"
|
||||
className="form-control"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="rarity">Rarity</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Grid */}
|
||||
{sortedBadges.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
<p className="mb-0">No badges found with the selected filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{sortedBadges.map((badge) => (
|
||||
<div key={badge._id} className="col-md-4 col-lg-3 mb-4">
|
||||
<BadgeDisplay badge={badge} isEarned={badge.isEarned} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeCollection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* BadgeDisplay component - displays a single badge with icon, name, and description
|
||||
* @param {Object} badge - Badge object
|
||||
* @param {boolean} isEarned - Whether the badge is earned
|
||||
* @param {boolean} showTooltip - Whether to show tooltip on hover
|
||||
*/
|
||||
const BadgeDisplay = ({ badge, isEarned = false, showTooltip = true }) => {
|
||||
const getRarityColor = (rarity) => {
|
||||
switch (rarity) {
|
||||
case "common":
|
||||
return "#6c757d";
|
||||
case "rare":
|
||||
return "#0d6efd";
|
||||
case "epic":
|
||||
return "#6f42c1";
|
||||
case "legendary":
|
||||
return "#ffc107";
|
||||
default:
|
||||
return "#6c757d";
|
||||
}
|
||||
};
|
||||
|
||||
const badgeStyle = {
|
||||
filter: isEarned ? "none" : "grayscale(100%) opacity(0.5)",
|
||||
borderColor: getRarityColor(badge.rarity),
|
||||
borderWidth: "3px",
|
||||
transition: "all 0.3s ease",
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
fontSize: "3rem",
|
||||
marginBottom: "0.5rem",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card badge-card text-center p-3"
|
||||
style={badgeStyle}
|
||||
title={
|
||||
showTooltip
|
||||
? `${badge.name} - ${badge.description}\n${
|
||||
badge.criteria?.type
|
||||
? `Unlock: ${badge.criteria.threshold} ${badge.criteria.type.replace(
|
||||
"_",
|
||||
" "
|
||||
)}`
|
||||
: ""
|
||||
}`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div style={iconStyle}>{badge.icon || "🏆"}</div>
|
||||
<h6 className="mb-1">{badge.name}</h6>
|
||||
<p className="small text-muted mb-1">{badge.description}</p>
|
||||
<span
|
||||
className={`badge badge-${badge.rarity === "legendary" ? "warning" : badge.rarity === "epic" ? "purple" : badge.rarity === "rare" ? "primary" : "secondary"}`}
|
||||
>
|
||||
{badge.rarity}
|
||||
</span>
|
||||
{isEarned && (
|
||||
<div className="mt-2">
|
||||
<span className="badge badge-success">✓ Earned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BadgeDisplay.propTypes = {
|
||||
badge: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
rarity: PropTypes.oneOf(["common", "rare", "epic", "legendary"]).isRequired,
|
||||
criteria: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
threshold: PropTypes.number,
|
||||
}),
|
||||
}).isRequired,
|
||||
isEarned: PropTypes.bool,
|
||||
showTooltip: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default BadgeDisplay;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* BadgeProgress component - displays progress bars for badges in progress
|
||||
* @param {Array} badges - Array of badge objects with progress information
|
||||
*/
|
||||
const BadgeProgress = ({ badges }) => {
|
||||
// Filter to show only badges that are in progress (not earned and have some progress)
|
||||
const inProgressBadges = badges.filter(
|
||||
(badge) => !badge.isEarned && badge.progress > 0 && badge.threshold > 0
|
||||
);
|
||||
|
||||
if (inProgressBadges.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-info">
|
||||
<p className="mb-0">
|
||||
Complete tasks and participate in events to earn badges!
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge-progress-container">
|
||||
<h5 className="mb-3">Badges In Progress</h5>
|
||||
{inProgressBadges.map((badge) => {
|
||||
const percentage = Math.round((badge.progress / badge.threshold) * 100);
|
||||
return (
|
||||
<div key={badge._id} className="mb-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-1">
|
||||
<div className="d-flex align-items-center">
|
||||
<span style={{ fontSize: "1.5rem", marginRight: "0.5rem" }}>
|
||||
{badge.icon || "🏆"}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{badge.name}</strong>
|
||||
<br />
|
||||
<small className="text-muted">{badge.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-info">
|
||||
{badge.progress} / {badge.threshold}
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress" style={{ height: "20px" }}>
|
||||
<div
|
||||
className={`progress-bar ${percentage >= 75 ? "bg-success" : percentage >= 50 ? "bg-info" : "bg-warning"}`}
|
||||
role="progressbar"
|
||||
style={{ width: `${percentage}%` }}
|
||||
aria-valuenow={badge.progress}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={badge.threshold}
|
||||
>
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BadgeProgress.propTypes = {
|
||||
badges: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
progress: PropTypes.number.isRequired,
|
||||
threshold: PropTypes.number.isRequired,
|
||||
isEarned: PropTypes.bool.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default BadgeProgress;
|
||||
@@ -0,0 +1,159 @@
|
||||
.contributors-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.contributors-list h3 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.metric-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-btn {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.metric-btn:hover {
|
||||
border-color: #28a745;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.metric-btn.active {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.contributors-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.contributors-table thead {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.contributors-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.contributors-table td {
|
||||
padding: 15px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.contributors-table tbody tr {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.contributors-table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.rank-cell.rank-1 {
|
||||
color: #ffd700;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.rank-cell.rank-2 {
|
||||
color: #c0c0c0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rank-cell.rank-3 {
|
||||
color: #cd7f32;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.no-contributors {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contributors-list {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.contributors-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.contributors-table th,
|
||||
.contributors-table td {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import "./ContributorsList.css";
|
||||
|
||||
const ContributorsList = ({ contributors }) => {
|
||||
if (!contributors || contributors.length === 0) {
|
||||
return <p className="text-muted">No contributors data available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contributors-list">
|
||||
{contributors.map((contributor, index) => (
|
||||
<div key={contributor.userId} className="contributor-item">
|
||||
<div className="contributor-rank">#{index + 1}</div>
|
||||
<div className="contributor-avatar">
|
||||
{contributor.profilePicture ? (
|
||||
<img
|
||||
src={contributor.profilePicture}
|
||||
alt={contributor.name}
|
||||
className="avatar-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{contributor.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="contributor-info">
|
||||
<div className="contributor-name">
|
||||
{contributor.name}
|
||||
{contributor.isPremium && (
|
||||
<span className="badge bg-warning text-dark ms-2">Premium</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="contributor-stats">
|
||||
<small className="text-muted">
|
||||
{contributor.stats.points} pts | {contributor.stats.tasksCompleted} tasks |{" "}
|
||||
{contributor.stats.streetsAdopted} streets
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contributor-score">
|
||||
<strong>{contributor.score}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContributorsList;
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import LeaderboardCard from "./LeaderboardCard";
|
||||
|
||||
/**
|
||||
* Leaderboard component displays top users by points with different timeframes
|
||||
*/
|
||||
const Leaderboard = () => {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [activeTab, setActiveTab] = useState("global");
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const limit = 50;
|
||||
|
||||
// Load leaderboard data based on active tab
|
||||
const loadLeaderboard = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let endpoint = "";
|
||||
|
||||
switch (activeTab) {
|
||||
case "global":
|
||||
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
|
||||
break;
|
||||
case "weekly":
|
||||
endpoint = `/api/leaderboard/weekly?limit=${limit}&offset=${offset}`;
|
||||
break;
|
||||
case "monthly":
|
||||
endpoint = `/api/leaderboard/monthly?limit=${limit}&offset=${offset}`;
|
||||
break;
|
||||
case "friends":
|
||||
endpoint = `/api/leaderboard/friends?limit=${limit}&offset=${offset}`;
|
||||
break;
|
||||
default:
|
||||
endpoint = `/api/leaderboard/global?limit=${limit}&offset=${offset}`;
|
||||
}
|
||||
|
||||
const config = {};
|
||||
// Friends endpoint requires authentication
|
||||
if (activeTab === "friends") {
|
||||
const token = localStorage.getItem("token");
|
||||
config.headers = { "x-auth-token": token };
|
||||
}
|
||||
|
||||
const res = await axios.get(endpoint, config);
|
||||
setLeaderboard(res.data);
|
||||
setHasMore(res.data.length === limit);
|
||||
} catch (err) {
|
||||
console.error("Error loading leaderboard:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.msg ||
|
||||
err.response?.data?.message ||
|
||||
"Failed to load leaderboard. Please try again later.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, page]);
|
||||
|
||||
// Load leaderboard stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get("/api/leaderboard/stats");
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading leaderboard stats:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLeaderboard();
|
||||
}, [loadLeaderboard]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (tab) => {
|
||||
if (tab === "friends" && !auth.isAuthenticated) {
|
||||
toast.warning("Please login to view friends leaderboard");
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
setPage(1);
|
||||
setLeaderboard([]);
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePreviousPage = () => {
|
||||
if (page > 1) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && leaderboard.length === 0) {
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
<p>Loading leaderboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger m-3" role="alert">
|
||||
<h4 className="alert-heading">Error Loading Leaderboard</h4>
|
||||
<p>{error}</p>
|
||||
<hr />
|
||||
<button className="btn btn-primary" onClick={loadLeaderboard}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Leaderboard</h1>
|
||||
|
||||
{/* Leaderboard Stats */}
|
||||
{stats && (
|
||||
<div className="alert alert-info mb-4">
|
||||
<div className="row">
|
||||
<div className="col-md-3 col-6 mb-2">
|
||||
<strong>Total Users:</strong> {stats.totalUsers}
|
||||
</div>
|
||||
<div className="col-md-3 col-6 mb-2">
|
||||
<strong>Total Points:</strong> {stats.totalPoints.toLocaleString()}
|
||||
</div>
|
||||
<div className="col-md-3 col-6 mb-2">
|
||||
<strong>Avg Points:</strong> {Math.round(stats.averagePoints)}
|
||||
</div>
|
||||
<div className="col-md-3 col-6 mb-2">
|
||||
<strong>Top Score:</strong> {stats.maxPoints.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<ul className="nav nav-tabs mb-4">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "global" ? "active" : ""}`}
|
||||
onClick={() => handleTabChange("global")}
|
||||
>
|
||||
All Time
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "weekly" ? "active" : ""}`}
|
||||
onClick={() => handleTabChange("weekly")}
|
||||
>
|
||||
This Week
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "monthly" ? "active" : ""}`}
|
||||
onClick={() => handleTabChange("monthly")}
|
||||
>
|
||||
This Month
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${activeTab === "friends" ? "active" : ""}`}
|
||||
onClick={() => handleTabChange("friends")}
|
||||
disabled={!auth.isAuthenticated}
|
||||
>
|
||||
Friends {!auth.isAuthenticated && "(Login Required)"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Current User Info */}
|
||||
{auth.user && activeTab !== "friends" && (
|
||||
<div className="alert alert-success mb-4">
|
||||
<strong>Your Points:</strong>{" "}
|
||||
<span className="badge badge-primary">{auth.user.points || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard List */}
|
||||
{leaderboard.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
{activeTab === "friends"
|
||||
? "No friends to display. Add friends to see them on the leaderboard!"
|
||||
: "No users to display yet. Be the first to earn points!"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row">
|
||||
{leaderboard.map((entry, index) => (
|
||||
<LeaderboardCard
|
||||
key={entry.userId}
|
||||
entry={entry}
|
||||
rank={(page - 1) * limit + index + 1}
|
||||
isCurrentUser={auth.user && entry.userId === auth.user._id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="d-flex justify-content-between align-items-center mt-4">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={page === 1 || loading}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} {hasMore && "- More available"}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm mr-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Next"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Leaderboard;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* LeaderboardCard component displays individual user entry on the leaderboard
|
||||
* @param {Object} entry - Leaderboard entry data
|
||||
* @param {Number} rank - User's rank/position
|
||||
* @param {Boolean} isCurrentUser - Whether this is the logged-in user
|
||||
*/
|
||||
const LeaderboardCard = ({ entry, rank, isCurrentUser }) => {
|
||||
// Determine rank badge color
|
||||
const getRankBadgeClass = () => {
|
||||
if (rank === 1) return "badge-warning"; // Gold
|
||||
if (rank === 2) return "badge-secondary"; // Silver
|
||||
if (rank === 3) return "badge-danger"; // Bronze
|
||||
return "badge-primary"; // Default
|
||||
};
|
||||
|
||||
// Get rank emoji
|
||||
const getRankEmoji = () => {
|
||||
if (rank === 1) return "🥇";
|
||||
if (rank === 2) return "🥈";
|
||||
if (rank === 3) return "🥉";
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-12 mb-3">
|
||||
<div
|
||||
className={`card h-100 ${isCurrentUser ? "border-success" : ""}`}
|
||||
style={isCurrentUser ? { borderWidth: "3px" } : {}}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="row align-items-center">
|
||||
{/* Rank */}
|
||||
<div className="col-2 col-md-1 text-center">
|
||||
<h3 className="mb-0">
|
||||
<span className={`badge ${getRankBadgeClass()}`}>
|
||||
{getRankEmoji()} #{rank}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="col-10 col-md-5">
|
||||
<h5 className="mb-1">
|
||||
{entry.username || "Unknown User"}
|
||||
{isCurrentUser && (
|
||||
<span className="badge badge-success ml-2">You</span>
|
||||
)}
|
||||
</h5>
|
||||
<div className="text-muted small">
|
||||
{entry.email && <div>{entry.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
|
||||
<div className="text-muted small">Points</div>
|
||||
<h4 className="mb-0 text-primary">
|
||||
{entry.points.toLocaleString()}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="col-6 col-md-2 text-center mt-2 mt-md-0">
|
||||
<div className="text-muted small">Streets</div>
|
||||
<div className="font-weight-bold">{entry.streetsAdopted || 0}</div>
|
||||
<div className="text-muted small mt-1">Tasks</div>
|
||||
<div className="font-weight-bold">{entry.tasksCompleted || 0}</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="col-12 col-md-2 text-center mt-2 mt-md-0">
|
||||
<div className="text-muted small">Badges</div>
|
||||
<div className="d-flex justify-content-center flex-wrap">
|
||||
{entry.badges && entry.badges.length > 0 ? (
|
||||
entry.badges.slice(0, 5).map((badge, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="badge badge-info mr-1 mb-1"
|
||||
title={badge.name}
|
||||
>
|
||||
{badge.icon || "🏆"}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted small">None</span>
|
||||
)}
|
||||
{entry.badges && entry.badges.length > 5 && (
|
||||
<span className="badge badge-secondary ml-1">
|
||||
+{entry.badges.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardCard;
|
||||
@@ -22,6 +22,12 @@ const Navbar = () => {
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/rewards">Rewards</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/leaderboard">Leaderboard</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/analytics">Analytics</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/profile">Profile</Link>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.personal-stats {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.personal-stats-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.personal-stats-header h3 {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.personal-stats-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.personal-stat-card {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px rgba(40, 167, 69, 0.2);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.personal-stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.personal-stat-card h4 {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-stat-card .value {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.personal-stat-card .label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-container h4 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-timeline {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.activity-timeline h4 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breakdown-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.breakdown-item .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #28a745;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.breakdown-item .label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
color: #333;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 15px;
|
||||
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.loading-personal-stats {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.no-personal-data {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.no-personal-data p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.get-started-btn {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.get-started-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.personal-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.personal-stat-card .value {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.breakdown-stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import axios from "axios";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import "./PersonalStats.css";
|
||||
|
||||
const PersonalStats = ({ timeframe }) => {
|
||||
const { user } = useContext(AuthContext);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchPersonalStats = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const config = {
|
||||
headers: {
|
||||
"x-auth-token": token,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await axios.get(
|
||||
`/api/analytics/user/${user._id}?timeframe=${timeframe}`,
|
||||
config
|
||||
);
|
||||
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching personal stats:", err);
|
||||
setError(err.response?.data?.msg || "Failed to load personal statistics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPersonalStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeframe, user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const chartData = [
|
||||
{ name: "Streets", value: stats.stats.streetsAdopted },
|
||||
{ name: "Tasks", value: stats.stats.tasksCompleted },
|
||||
{ name: "Posts", value: stats.stats.postsCreated },
|
||||
{ name: "Events", value: stats.stats.eventsParticipated },
|
||||
{ name: "Badges", value: stats.stats.badgesEarned },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="personal-stats">
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-12">
|
||||
<div className="user-header">
|
||||
<h3>{stats.user.name}</h3>
|
||||
<div className="user-badges">
|
||||
{stats.user.isPremium && (
|
||||
<span className="badge bg-warning text-dark">Premium</span>
|
||||
)}
|
||||
<span className="badge bg-primary">{stats.user.points} Points</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.streetsAdopted}</h4>
|
||||
<p>Streets</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.tasksCompleted}</h4>
|
||||
<p>Tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.postsCreated}</h4>
|
||||
<p>Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.eventsParticipated}</h4>
|
||||
<p>Events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.badgesEarned}</h4>
|
||||
<p>Badges</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="stat-box">
|
||||
<h4>{stats.stats.totalLikesReceived}</h4>
|
||||
<p>Likes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Activity Overview</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="value" fill="#0d6efd" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Points Summary</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="points-summary">
|
||||
<div className="points-row">
|
||||
<span className="points-label">Points Earned:</span>
|
||||
<span className="points-value text-success">
|
||||
+{stats.stats.pointsEarned}
|
||||
</span>
|
||||
</div>
|
||||
<div className="points-row">
|
||||
<span className="points-label">Points Spent:</span>
|
||||
<span className="points-value text-danger">
|
||||
-{stats.stats.pointsSpent}
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="points-row">
|
||||
<span className="points-label">
|
||||
<strong>Current Balance:</strong>
|
||||
</span>
|
||||
<span className="points-value">
|
||||
<strong>{stats.user.points}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h6>Engagement Metrics</h6>
|
||||
<div className="engagement-metrics">
|
||||
<div className="metric">
|
||||
<span className="metric-icon">
|
||||
<i className="fas fa-heart"></i>
|
||||
</span>
|
||||
<span className="metric-value">
|
||||
{stats.stats.totalLikesReceived}
|
||||
</span>
|
||||
<span className="metric-label">Likes Received</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span className="metric-icon">
|
||||
<i className="fas fa-comment"></i>
|
||||
</span>
|
||||
<span className="metric-value">
|
||||
{stats.stats.totalCommentsReceived}
|
||||
</span>
|
||||
<span className="metric-label">Comments Received</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.recentActivity && (
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5>Recent Activity</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-4">
|
||||
<h6>Recent Tasks</h6>
|
||||
{stats.recentActivity.tasks.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{stats.recentActivity.tasks.map((task) => (
|
||||
<li key={task._id} className="mb-2">
|
||||
<small className="text-muted">
|
||||
{new Date(task.completedAt || task.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
<div>{task.description}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">No recent tasks</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<h6>Recent Posts</h6>
|
||||
{stats.recentActivity.posts.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{stats.recentActivity.posts.map((post) => (
|
||||
<li key={post._id} className="mb-2">
|
||||
<small className="text-muted">
|
||||
{new Date(post.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
<div>{post.content.substring(0, 50)}...</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">No recent posts</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<h6>Recent Events</h6>
|
||||
{stats.recentActivity.events.length > 0 ? (
|
||||
<ul className="list-unstyled">
|
||||
{stats.recentActivity.events.map((event) => (
|
||||
<li key={event._id} className="mb-2">
|
||||
<small className="text-muted">
|
||||
{new Date(event.date).toLocaleDateString()}
|
||||
</small>
|
||||
<div>{event.title}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted">No recent events</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalStats;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
|
||||
|
||||
const StreetStatsChart = ({ data }) => {
|
||||
if (!data) return null;
|
||||
|
||||
const adoptionData = [
|
||||
{ name: "Adopted", value: data.adoption.adoptedStreets, color: "#198754" },
|
||||
{ name: "Available", value: data.adoption.availableStreets, color: "#6c757d" },
|
||||
];
|
||||
|
||||
const taskData = [
|
||||
{ name: "Completed", value: data.tasks.completedTasks, color: "#0d6efd" },
|
||||
{ name: "Pending", value: data.tasks.pendingTasks, color: "#ffc107" },
|
||||
{ name: "In Progress", value: data.tasks.inProgressTasks, color: "#fd7e14" },
|
||||
];
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderCustomizedLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-4">
|
||||
<div className="stat-highlight">
|
||||
<h3>{data.adoption.adoptionRate}%</h3>
|
||||
<p className="text-muted">Adoption Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="stat-highlight">
|
||||
<h3>{data.tasks.completionRate}%</h3>
|
||||
<p className="text-muted">Task Completion Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="stat-highlight">
|
||||
<h3>{data.adoption.totalStreets}</h3>
|
||||
<p className="text-muted">Total Streets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<h6 className="text-center mb-3">Street Adoption</h6>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={adoptionData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomizedLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{adoptionData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<h6 className="text-center mb-3">Task Status</h6>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={taskData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomizedLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{taskData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.topStreets && data.topStreets.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h6>Top Streets by Task Completion</h6>
|
||||
<div className="list-group">
|
||||
{data.topStreets.slice(0, 5).map((street, index) => (
|
||||
<div key={street.streetId} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>#{index + 1}</strong> {street.streetName}
|
||||
</span>
|
||||
<span className="badge bg-primary rounded-pill">{street.count} tasks</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreetStatsChart;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect, useContext } from \"react\";
|
||||
import { useParams, Link } from \"react-router-dom\";
|
||||
import axios from \"axios\";
|
||||
const { AuthContext } = require(\"../../context/AuthContext\");
|
||||
|
||||
const ProfileView = () => {
|
||||
const { userId } = useParams();
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await axios.get((/api/profile/${userId}`);
|
||||
setProfile(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.ager = == auth.user._id;
|
||||
|
||||
return (
|
||||
<div className=\"container mt-5\">
|
||||
<div className=\"row\">
|
||||
<div className=\"col-md-4 text-center\">
|
||||
<img
|
||||
src={`npame"} id: auth.user._id });
|
||||
setProfile(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.msg || \"Error fetching profile\");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
}, [userId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className=\"container\"><p>Loading profile...</p></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className=\"container\"><div className=\"alert alert-danger\">{error}</div></div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return <div className=\"container\"><p>Profile not found.</p></div>;
|
||||
}
|
||||
|
||||
const { name, avatar, bio, location, website, social, preferences } = profile;
|
||||
const isOwnProfile = auth.isAuthenticated && auth.user._id === userId;
|
||||
|
||||
return (
|
||||
<div className=\"container mt-5\">
|
||||
<div className=\"row\">
|
||||
<div className=\"col-md-4 text-center\">
|
||||
<img
|
||||
src={avatar || \"/logo512.png\"}
|
||||
alt={`${name}\'s avatar`}
|
||||
className=\"img-fluid rounded-circle mb-3\"
|
||||
style={{ width: \"150px\", height: \"150px\" }}
|
||||
/>
|
||||
<h3>{name}</h3>
|
||||
{location && <p className=\"text-muted\">{location}</p>}
|
||||
{isOwnProfile && (
|
||||
<Link to=\"/profile/edit\" className=\"btn btn-primary mb-3\">Edit Profile</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className=\"col-md-8\">
|
||||
<div className=\"card\">
|
||||
<div className=\"card-body\">
|
||||
|
||||
Reference in New Issue
Block a user