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:
@@ -1,4 +1,18 @@
|
||||
// This file runs before any modules are loaded
|
||||
|
||||
// Set test environment variables FIRST (before any module loads)
|
||||
// Must be at least 32 chars for validation
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
|
||||
process.env.COUCHDB_URL = 'http://localhost:5984';
|
||||
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
|
||||
process.env.PORT = '5001';
|
||||
|
||||
// Mock dotenv to prevent .env file from overriding test values
|
||||
jest.mock('dotenv', () => ({
|
||||
config: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock axios first since couchdbService uses it
|
||||
jest.mock('axios', () => ({
|
||||
create: jest.fn(() => ({
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
const request = require("supertest");
|
||||
const { app } = require("../../server");
|
||||
const couchdbService = require("../../services/couchdbService");
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
// Mock couchdbService
|
||||
jest.mock("../../services/couchdbService");
|
||||
|
||||
describe("Analytics API", () => {
|
||||
let authToken;
|
||||
let mockUser;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a mock user and token
|
||||
mockUser = {
|
||||
_id: "user_123",
|
||||
type: "user",
|
||||
name: "Test User",
|
||||
email: "test@test.com",
|
||||
points: 100,
|
||||
isPremium: false,
|
||||
adoptedStreets: ["street_1"],
|
||||
completedTasks: ["task_1", "task_2"],
|
||||
posts: ["post_1"],
|
||||
events: ["event_1"],
|
||||
earnedBadges: [{ badgeId: "badge_1" }],
|
||||
stats: {
|
||||
streetsAdopted: 1,
|
||||
tasksCompleted: 2,
|
||||
postsCreated: 1,
|
||||
eventsParticipated: 1,
|
||||
badgesEarned: 1,
|
||||
},
|
||||
};
|
||||
|
||||
authToken = jwt.sign({ id: mockUser._id }, process.env.JWT_SECRET || "test_secret");
|
||||
|
||||
// Mock couchdbService methods
|
||||
couchdbService.find = jest.fn();
|
||||
couchdbService.findUserById = jest.fn();
|
||||
couchdbService.getDocument = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/analytics/overview", () => {
|
||||
it("should return overview statistics without auth token", async () => {
|
||||
const mockUsers = [
|
||||
{ type: "user", points: 100 },
|
||||
{ type: "user", points: 150 },
|
||||
];
|
||||
const mockStreets = [
|
||||
{ type: "street", status: "adopted" },
|
||||
{ type: "street", status: "available" },
|
||||
{ type: "street", status: "adopted" },
|
||||
];
|
||||
const mockTasks = [
|
||||
{ type: "task", status: "completed" },
|
||||
{ type: "task", status: "pending" },
|
||||
];
|
||||
const mockEvents = [
|
||||
{ type: "event", status: "upcoming" },
|
||||
{ type: "event", status: "completed" },
|
||||
];
|
||||
const mockPosts = [{ type: "post" }, { type: "post" }];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "user") return mockUsers;
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/overview")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("overview");
|
||||
expect(res.body.overview).toHaveProperty("totalUsers", 2);
|
||||
expect(res.body.overview).toHaveProperty("totalStreets", 3);
|
||||
expect(res.body.overview).toHaveProperty("adoptedStreets", 2);
|
||||
expect(res.body.overview).toHaveProperty("totalTasks", 2);
|
||||
expect(res.body.overview).toHaveProperty("completedTasks", 1);
|
||||
expect(res.body.overview).toHaveProperty("totalEvents", 2);
|
||||
expect(res.body.overview).toHaveProperty("totalPosts", 2);
|
||||
expect(res.body.overview).toHaveProperty("totalPoints", 250);
|
||||
expect(res.body.overview).toHaveProperty("averagePointsPerUser", 125);
|
||||
});
|
||||
|
||||
it("should filter by timeframe", async () => {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const mockUsers = [{ type: "user", points: 100 }];
|
||||
const mockStreets = [{ type: "street", status: "adopted" }];
|
||||
const mockTasks = [
|
||||
{ type: "task", status: "completed", createdAt: now.toISOString() },
|
||||
];
|
||||
const mockEvents = [
|
||||
{ type: "event", status: "upcoming", createdAt: now.toISOString() },
|
||||
];
|
||||
const mockPosts = [{ type: "post", createdAt: now.toISOString() }];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "user") return mockUsers;
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/overview?timeframe=7d")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.timeframe).toBe("7d");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app).get("/api/analytics/overview");
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics/user/:userId", () => {
|
||||
it("should return user-specific analytics", async () => {
|
||||
couchdbService.findUserById.mockResolvedValue(mockUser);
|
||||
couchdbService.getDocument.mockResolvedValue({
|
||||
_id: "street_1",
|
||||
name: "Main Street",
|
||||
});
|
||||
|
||||
const mockTasks = [
|
||||
{ type: "task", completedBy: { userId: mockUser._id }, createdAt: new Date().toISOString() },
|
||||
];
|
||||
const mockPosts = [
|
||||
{ type: "post", user: { userId: mockUser._id }, likesCount: 5, commentsCount: 3, createdAt: new Date().toISOString() },
|
||||
];
|
||||
const mockEvents = [
|
||||
{ type: "event", participants: [{ userId: mockUser._id }], createdAt: new Date().toISOString() },
|
||||
];
|
||||
const mockTransactions = [
|
||||
{ type: "point_transaction", user: { userId: mockUser._id }, amount: 50, createdAt: new Date().toISOString() },
|
||||
{ type: "point_transaction", user: { userId: mockUser._id }, amount: -20, createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "point_transaction") return mockTransactions;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/analytics/user/${mockUser._id}`)
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("user");
|
||||
expect(res.body.user.name).toBe("Test User");
|
||||
expect(res.body).toHaveProperty("stats");
|
||||
expect(res.body.stats).toHaveProperty("streetsAdopted");
|
||||
expect(res.body.stats).toHaveProperty("tasksCompleted");
|
||||
expect(res.body.stats).toHaveProperty("pointsEarned", 50);
|
||||
expect(res.body.stats).toHaveProperty("pointsSpent", 20);
|
||||
expect(res.body.stats).toHaveProperty("totalLikesReceived", 5);
|
||||
expect(res.body.stats).toHaveProperty("totalCommentsReceived", 3);
|
||||
expect(res.body).toHaveProperty("recentActivity");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent user", async () => {
|
||||
couchdbService.findUserById.mockResolvedValue(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/user/invalid_user_id")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body.msg).toBe("User not found");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app).get(`/api/analytics/user/${mockUser._id}`);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics/activity", () => {
|
||||
it("should return activity data grouped by day", async () => {
|
||||
const today = new Date().toISOString();
|
||||
const mockTasks = [{ type: "task", createdAt: today }];
|
||||
const mockPosts = [{ type: "post", createdAt: today }];
|
||||
const mockEvents = [{ type: "event", createdAt: today }];
|
||||
const mockStreets = [{ type: "street", status: "adopted", createdAt: today }];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/activity?groupBy=day")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("activity");
|
||||
expect(res.body).toHaveProperty("groupBy", "day");
|
||||
expect(res.body).toHaveProperty("summary");
|
||||
expect(Array.isArray(res.body.activity)).toBe(true);
|
||||
});
|
||||
|
||||
it("should group activity by week", async () => {
|
||||
const today = new Date().toISOString();
|
||||
const mockTasks = [{ type: "task", createdAt: today }];
|
||||
const mockPosts = [];
|
||||
const mockEvents = [];
|
||||
const mockStreets = [];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/activity?groupBy=week")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.groupBy).toBe("week");
|
||||
});
|
||||
|
||||
it("should group activity by month", async () => {
|
||||
const today = new Date().toISOString();
|
||||
const mockTasks = [{ type: "task", createdAt: today }];
|
||||
const mockPosts = [];
|
||||
const mockEvents = [];
|
||||
const mockStreets = [];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
if (query.selector.type === "post") return mockPosts;
|
||||
if (query.selector.type === "event") return mockEvents;
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/activity?groupBy=month")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.groupBy).toBe("month");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app).get("/api/analytics/activity");
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics/top-contributors", () => {
|
||||
it("should return top contributors by points", async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
_id: "user_1",
|
||||
name: "User 1",
|
||||
email: "user1@test.com",
|
||||
points: 500,
|
||||
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
|
||||
earnedBadges: [],
|
||||
},
|
||||
{
|
||||
_id: "user_2",
|
||||
name: "User 2",
|
||||
email: "user2@test.com",
|
||||
points: 300,
|
||||
stats: { tasksCompleted: 7, postsCreated: 3, streetsAdopted: 1 },
|
||||
earnedBadges: [],
|
||||
},
|
||||
];
|
||||
|
||||
couchdbService.find.mockResolvedValue(mockUsers);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/top-contributors?limit=5")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("contributors");
|
||||
expect(res.body.contributors.length).toBeLessThanOrEqual(5);
|
||||
expect(res.body.metric).toBe("points");
|
||||
// Verify sorted by score descending
|
||||
if (res.body.contributors.length > 1) {
|
||||
expect(res.body.contributors[0].score).toBeGreaterThanOrEqual(
|
||||
res.body.contributors[1].score
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return top contributors by tasks", async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
_id: "user_1",
|
||||
name: "User 1",
|
||||
email: "user1@test.com",
|
||||
points: 500,
|
||||
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
|
||||
earnedBadges: [],
|
||||
},
|
||||
];
|
||||
|
||||
couchdbService.find.mockResolvedValue(mockUsers);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/top-contributors?metric=tasks")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.metric).toBe("tasks");
|
||||
});
|
||||
|
||||
it("should return top contributors by posts", async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
_id: "user_1",
|
||||
name: "User 1",
|
||||
email: "user1@test.com",
|
||||
points: 500,
|
||||
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
|
||||
earnedBadges: [],
|
||||
},
|
||||
];
|
||||
|
||||
couchdbService.find.mockResolvedValue(mockUsers);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/top-contributors?metric=posts")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.metric).toBe("posts");
|
||||
});
|
||||
|
||||
it("should return top contributors by streets", async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
_id: "user_1",
|
||||
name: "User 1",
|
||||
email: "user1@test.com",
|
||||
points: 500,
|
||||
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
|
||||
earnedBadges: [],
|
||||
},
|
||||
];
|
||||
|
||||
couchdbService.find.mockResolvedValue(mockUsers);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/top-contributors?metric=streets")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.metric).toBe("streets");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app).get("/api/analytics/top-contributors");
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/analytics/street-stats", () => {
|
||||
it("should return street adoption and task statistics", async () => {
|
||||
const mockStreets = [
|
||||
{ type: "street", status: "adopted" },
|
||||
{ type: "street", status: "available" },
|
||||
{ type: "street", status: "adopted" },
|
||||
];
|
||||
const mockTasks = [
|
||||
{
|
||||
type: "task",
|
||||
status: "completed",
|
||||
street: { streetId: "street_1", name: "Main St" },
|
||||
},
|
||||
{
|
||||
type: "task",
|
||||
status: "completed",
|
||||
street: { streetId: "street_1", name: "Main St" },
|
||||
},
|
||||
{ type: "task", status: "pending", street: { streetId: "street_2", name: "Oak St" } },
|
||||
];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/street-stats")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toHaveProperty("adoption");
|
||||
expect(res.body.adoption).toHaveProperty("totalStreets", 3);
|
||||
expect(res.body.adoption).toHaveProperty("adoptedStreets", 2);
|
||||
expect(res.body.adoption).toHaveProperty("availableStreets", 1);
|
||||
expect(res.body.adoption).toHaveProperty("adoptionRate");
|
||||
expect(res.body).toHaveProperty("tasks");
|
||||
expect(res.body.tasks).toHaveProperty("totalTasks", 3);
|
||||
expect(res.body.tasks).toHaveProperty("completedTasks", 2);
|
||||
expect(res.body.tasks).toHaveProperty("pendingTasks", 1);
|
||||
expect(res.body.tasks).toHaveProperty("completionRate");
|
||||
expect(res.body).toHaveProperty("topStreets");
|
||||
expect(Array.isArray(res.body.topStreets)).toBe(true);
|
||||
});
|
||||
|
||||
it("should calculate correct adoption rate", async () => {
|
||||
const mockStreets = [
|
||||
{ type: "street", status: "adopted" },
|
||||
{ type: "street", status: "adopted" },
|
||||
{ type: "street", status: "available" },
|
||||
{ type: "street", status: "available" },
|
||||
];
|
||||
const mockTasks = [];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/street-stats")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.adoption.adoptionRate).toBe(50.0);
|
||||
});
|
||||
|
||||
it("should calculate correct task completion rate", async () => {
|
||||
const mockStreets = [];
|
||||
const mockTasks = [
|
||||
{ type: "task", status: "completed" },
|
||||
{ type: "task", status: "completed" },
|
||||
{ type: "task", status: "completed" },
|
||||
{ type: "task", status: "pending" },
|
||||
];
|
||||
|
||||
couchdbService.find.mockImplementation(async (query) => {
|
||||
if (query.selector.type === "street") return mockStreets;
|
||||
if (query.selector.type === "task") return mockTasks;
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/analytics/street-stats")
|
||||
.set("x-auth-token", authToken);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.tasks.completionRate).toBe(75.0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app).get("/api/analytics/street-stats");
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
const request = require("supertest");
|
||||
const { app, server } = require("../../server");
|
||||
const couchdbService = require("../../services/couchdbService");
|
||||
const User = require("../../models/User");
|
||||
const PointTransaction = require("../../models/PointTransaction");
|
||||
const Badge = require("../../models/Badge");
|
||||
const UserBadge = require("../../models/UserBadge");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { clearCache } = require("../../middleware/cache");
|
||||
|
||||
describe("Leaderboard Routes", () => {
|
||||
let testUsers = [];
|
||||
let authToken;
|
||||
let testUserId;
|
||||
|
||||
beforeAll(async () => {
|
||||
await couchdbService.initialize();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear cache before each test
|
||||
clearCache();
|
||||
|
||||
// Create test users with varying points
|
||||
const userPromises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const userData = {
|
||||
name: `Test User ${i}`,
|
||||
email: `testuser${i}@example.com`,
|
||||
password: "password123",
|
||||
points: (10 - i) * 100, // 1000, 900, 800, ..., 100
|
||||
adoptedStreets: [],
|
||||
completedTasks: [],
|
||||
stats: {
|
||||
streetsAdopted: i,
|
||||
tasksCompleted: i * 2,
|
||||
postsCreated: i,
|
||||
eventsParticipated: i,
|
||||
badgesEarned: 0
|
||||
}
|
||||
};
|
||||
userPromises.push(User.create(userData));
|
||||
}
|
||||
testUsers = await Promise.all(userPromises);
|
||||
|
||||
// Set test user ID and create auth token
|
||||
testUserId = testUsers[0]._id;
|
||||
authToken = jwt.sign({ id: testUserId }, process.env.JWT_SECRET, {
|
||||
expiresIn: "1h"
|
||||
});
|
||||
|
||||
// Create some point transactions for weekly/monthly leaderboards
|
||||
const now = new Date();
|
||||
const transactionPromises = [];
|
||||
|
||||
// Create transactions for this week
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const weeklyTransaction = {
|
||||
user: testUsers[i]._id,
|
||||
amount: (5 - i) * 50,
|
||||
transactionType: "earned",
|
||||
description: `Weekly test transaction ${i}`,
|
||||
balanceAfter: testUsers[i].points + (5 - i) * 50,
|
||||
createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000).toISOString()
|
||||
};
|
||||
transactionPromises.push(PointTransaction.create(weeklyTransaction));
|
||||
}
|
||||
|
||||
// Create transactions for this month (but not this week)
|
||||
for (let i = 5; i < 8; i++) {
|
||||
const monthlyTransaction = {
|
||||
user: testUsers[i]._id,
|
||||
amount: (8 - i) * 30,
|
||||
transactionType: "earned",
|
||||
description: `Monthly test transaction ${i}`,
|
||||
balanceAfter: testUsers[i].points + (8 - i) * 30,
|
||||
createdAt: new Date(now.getTime() - (i + 5) * 24 * 60 * 60 * 1000).toISOString()
|
||||
};
|
||||
transactionPromises.push(PointTransaction.create(monthlyTransaction));
|
||||
}
|
||||
|
||||
await Promise.all(transactionPromises);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
const deletePromises = [];
|
||||
|
||||
// Delete test users
|
||||
for (const user of testUsers) {
|
||||
if (user._id && user._rev) {
|
||||
deletePromises.push(
|
||||
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test transactions
|
||||
const transactions = await couchdbService.find({
|
||||
selector: { type: "point_transaction" }
|
||||
});
|
||||
for (const transaction of transactions) {
|
||||
deletePromises.push(
|
||||
couchdbService.deleteDocument(transaction._id, transaction._rev).catch(() => {})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
testUsers = [];
|
||||
clearCache();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/global", () => {
|
||||
it("should get global leaderboard with top users", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
expect(res.body.data.length).toBeGreaterThan(0);
|
||||
expect(res.body.data.length).toBeLessThanOrEqual(100);
|
||||
|
||||
// Check first user has highest points
|
||||
const firstUser = res.body.data[0];
|
||||
expect(firstUser).toHaveProperty("rank", 1);
|
||||
expect(firstUser).toHaveProperty("userId");
|
||||
expect(firstUser).toHaveProperty("username");
|
||||
expect(firstUser).toHaveProperty("points");
|
||||
expect(firstUser).toHaveProperty("streetsAdopted");
|
||||
expect(firstUser).toHaveProperty("tasksCompleted");
|
||||
expect(firstUser).toHaveProperty("badges");
|
||||
|
||||
// Verify sorting by points descending
|
||||
for (let i = 0; i < res.body.data.length - 1; i++) {
|
||||
expect(res.body.data[i].points).toBeGreaterThanOrEqual(
|
||||
res.body.data[i + 1].points
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global?limit=5")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.length).toBeLessThanOrEqual(5);
|
||||
expect(res.body.limit).toBe(5);
|
||||
});
|
||||
|
||||
it("should respect offset parameter", async () => {
|
||||
const res1 = await request(app)
|
||||
.get("/api/leaderboard/global?limit=5")
|
||||
.expect(200);
|
||||
|
||||
const res2 = await request(app)
|
||||
.get("/api/leaderboard/global?limit=5&offset=5")
|
||||
.expect(200);
|
||||
|
||||
expect(res2.body.success).toBe(true);
|
||||
expect(res2.body.offset).toBe(5);
|
||||
|
||||
// First user in second page should have lower points than last in first page
|
||||
if (res2.body.data.length > 0 && res1.body.data.length === 5) {
|
||||
expect(res2.body.data[0].points).toBeLessThanOrEqual(
|
||||
res1.body.data[4].points
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should cache leaderboard results", async () => {
|
||||
const res1 = await request(app)
|
||||
.get("/api/leaderboard/global")
|
||||
.expect(200);
|
||||
|
||||
const res2 = await request(app)
|
||||
.get("/api/leaderboard/global")
|
||||
.expect(200);
|
||||
|
||||
expect(res1.body).toEqual(res2.body);
|
||||
});
|
||||
|
||||
it("should limit maximum request to 500", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global?limit=1000")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.limit).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/weekly", () => {
|
||||
it("should get weekly leaderboard", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/weekly")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.timeframe).toBe("week");
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should only include points from this week", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/weekly")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Weekly leaderboard should have users with weekly transactions
|
||||
if (res.body.data.length > 0) {
|
||||
const firstUser = res.body.data[0];
|
||||
expect(firstUser).toHaveProperty("points");
|
||||
expect(firstUser).toHaveProperty("rank", 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("should respect limit and offset", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/weekly?limit=3&offset=1")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.limit).toBe(3);
|
||||
expect(res.body.offset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/monthly", () => {
|
||||
it("should get monthly leaderboard", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/monthly")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.timeframe).toBe("month");
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should only include points from this month", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/monthly")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Monthly leaderboard should have users with monthly transactions
|
||||
if (res.body.data.length > 0) {
|
||||
const firstUser = res.body.data[0];
|
||||
expect(firstUser).toHaveProperty("points");
|
||||
expect(firstUser).toHaveProperty("rank", 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/friends", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/friends")
|
||||
.expect(401);
|
||||
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should get friends leaderboard with authentication", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/friends")
|
||||
.set("x-auth-token", authToken)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should include user if no friends", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/friends")
|
||||
.set("x-auth-token", authToken)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
// Should at least include the user themselves
|
||||
const userEntry = res.body.data.find(entry => entry.userId === testUserId);
|
||||
if (res.body.data.length > 0) {
|
||||
expect(userEntry || res.body.data.length === 1).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/user/:userId", () => {
|
||||
it("should get user leaderboard position", async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/leaderboard/user/${testUserId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toHaveProperty("rank");
|
||||
expect(res.body.data).toHaveProperty("userId", testUserId);
|
||||
expect(res.body.data).toHaveProperty("username");
|
||||
expect(res.body.data).toHaveProperty("points");
|
||||
expect(res.body.data).toHaveProperty("totalUsers");
|
||||
expect(res.body.data).toHaveProperty("percentile");
|
||||
});
|
||||
|
||||
it("should support timeframe parameter", async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/leaderboard/user/${testUserId}?timeframe=week`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toHaveProperty("rank");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent user", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/user/nonexistent_user_id")
|
||||
.expect(404);
|
||||
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/leaderboard/stats", () => {
|
||||
it("should get leaderboard statistics", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/stats")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toHaveProperty("totalUsers");
|
||||
expect(res.body.data).toHaveProperty("totalPoints");
|
||||
expect(res.body.data).toHaveProperty("avgPoints");
|
||||
expect(res.body.data).toHaveProperty("maxPoints");
|
||||
expect(res.body.data).toHaveProperty("minPoints");
|
||||
expect(res.body.data).toHaveProperty("weeklyStats");
|
||||
|
||||
expect(res.body.data.weeklyStats).toHaveProperty("totalPoints");
|
||||
expect(res.body.data.weeklyStats).toHaveProperty("activeUsers");
|
||||
expect(res.body.data.weeklyStats).toHaveProperty("transactions");
|
||||
});
|
||||
|
||||
it("should cache statistics", async () => {
|
||||
const res1 = await request(app)
|
||||
.get("/api/leaderboard/stats")
|
||||
.expect(200);
|
||||
|
||||
const res2 = await request(app)
|
||||
.get("/api/leaderboard/stats")
|
||||
.expect(200);
|
||||
|
||||
expect(res1.body).toEqual(res2.body);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty leaderboard gracefully", async () => {
|
||||
// Delete all users first
|
||||
const deletePromises = testUsers.map(user =>
|
||||
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
|
||||
);
|
||||
await Promise.all(deletePromises);
|
||||
clearCache();
|
||||
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle invalid limit gracefully", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global?limit=invalid")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(typeof res.body.limit).toBe("number");
|
||||
});
|
||||
|
||||
it("should handle negative offset gracefully", async () => {
|
||||
const res = await request(app)
|
||||
.get("/api/leaderboard/global?offset=-5")
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const { body, validationResult } = require("express-validator");
|
||||
|
||||
const URL_REGEX = /^(https?|ftp):\\/\\/[^\\s\\/$.?#].[^\\s]*$/i;
|
||||
|
||||
const validateProfile = [
|
||||
body("bio")
|
||||
.optional()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage("Bio cannot exceed 500 characters."),
|
||||
body("location").optional().isString(),
|
||||
body("website")
|
||||
.optional()
|
||||
.if(body("website").notEmpty())
|
||||
.matches(URL_REGEX)
|
||||
.withMessage("Invalid website URL."),
|
||||
body("social.twitter")
|
||||
.optional()
|
||||
.if(body("social.twitter").notEmpty())
|
||||
.matches(URL_REGEX)
|
||||
.withMessage("Invalid Twitter URL."),
|
||||
body("social.github")
|
||||
.optional()
|
||||
.if(body("social.github").notEmpty())
|
||||
.matches(URL_REGEX)
|
||||
.withMessage("Invalid Github URL."),
|
||||
body("social.linkedin")
|
||||
.optional()
|
||||
.if(body("social.linkedin").notEmpty())
|
||||
.matches(URL_REGEX)
|
||||
.withMessage("Invalid LinkedIn URL."),
|
||||
body("privacySettings.profileVisibility")
|
||||
.optional()
|
||||
.isIn(["public", "private"])
|
||||
.withMessage("Profile visibility must be public or private."),
|
||||
body("preferences.emailNotifications").optional().isBoolean(),
|
||||
body("preferences.pushNotifications").optional().isBoolean(),
|
||||
body("preferences.theme")
|
||||
.optional()
|
||||
.isIn(["light", "dark"])
|
||||
.withMessage("Theme must be light or dark."),
|
||||
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = { validateProfile };
|
||||
|
||||
+62
-95
@@ -2,24 +2,18 @@ const bcrypt = require("bcryptjs");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
const {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
DatabaseError,
|
||||
DuplicateError,
|
||||
withErrorHandling,
|
||||
createErrorContext
|
||||
createErrorContext,
|
||||
} = require("../utils/modelErrors");
|
||||
|
||||
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
|
||||
|
||||
class User {
|
||||
constructor(data) {
|
||||
// Validate required fields
|
||||
if (!data.name) {
|
||||
throw new ValidationError('Name is required', 'name', data.name);
|
||||
}
|
||||
if (!data.email) {
|
||||
throw new ValidationError('Email is required', 'email', data.email);
|
||||
}
|
||||
if (!data.password) {
|
||||
throw new ValidationError('Password is required', 'password', data.password);
|
||||
constructor(data) { // Validate required fields for new user creation
|
||||
if (!data._id) { // Only for new users
|
||||
if (!data.name) { throw new ValidationError("Name is required", "name", data.name); }
|
||||
if (!data.email) { throw new ValidationError("Email is required", "email", data.email); }
|
||||
if (!data.password) { throw new ValidationError("Password is required", "password", data.password); }
|
||||
}
|
||||
|
||||
this._id = data._id || null;
|
||||
@@ -28,39 +22,60 @@ class User {
|
||||
this.name = data.name;
|
||||
this.email = data.email;
|
||||
this.password = data.password;
|
||||
|
||||
// --- Profile Information ---
|
||||
this.avatar = data.avatar || null;
|
||||
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
|
||||
this.bio = data.bio || "";
|
||||
if (this.bio.length > 510) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
|
||||
this.location = data.location || "";
|
||||
this.website = data.website || "";
|
||||
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
|
||||
|
||||
// --- Social Links ---
|
||||
this.social = data.social || { twitter: "", github: "", linkedin: "" };
|
||||
if (this.social.twitter && !URL_REGEX.test(this.social.twitter)) { throw new ValidationError("Invalid Twitter URL.", "social.twitter", this.social.twitter); }
|
||||
if (this.social.github && !URL_REGEX.test(this.social.github)) { throw new ValidationError("Invalid Github URL.", "social.github", this.social.github); }
|
||||
if (this.social.linkedin && !URL_REGEX.test(this.social.linkedin)) { throw new ValidationError("Invalid LinkedIn URL.", "social.linkedin", this.social.linkedin); }
|
||||
|
||||
// --- Settings & Preferences ---
|
||||
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
|
||||
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
|
||||
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
|
||||
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be light' or 'dark'.", "preferences.theme", this.preferences.theme); }
|
||||
|
||||
|
||||
// --- Gamification & App Data ---
|
||||
this.isPremium = data.isPremium || false;
|
||||
this.points = Math.max(0, data.points || 0); // Ensure non-negative
|
||||
this.points = Math.max(0, data.points || 0);
|
||||
this.adoptedStreets = data.adoptedStreets || [];
|
||||
this.completedTasks = data.completedTasks || [];
|
||||
this.posts = data.posts || [];
|
||||
this.events = data.events || [];
|
||||
this.profilePicture = data.profilePicture || null;
|
||||
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
|
||||
this.earnedBadges = data.earnedBadges || [];
|
||||
this.stats = data.stats || {
|
||||
streetsAdopted: 0,
|
||||
tasksCompleted: 0,
|
||||
postsCreated: 0,
|
||||
eventsParticipated: 0,
|
||||
badgesEarned: 0
|
||||
badgesEarned: 0,
|
||||
};
|
||||
|
||||
this.createdAt = data.createdAt || new Date().toISOString();
|
||||
this.updatedAt = data.updatedAt || new Date().toISOString();
|
||||
}
|
||||
|
||||
// ... (static methods remain the same)
|
||||
// Static methods for MongoDB compatibility
|
||||
static async findOne(query) {
|
||||
const errorContext = createErrorContext('User', 'findOne', { query });
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
let user;
|
||||
if (query.email) {
|
||||
user = await couchdbService.findUserByEmail(query.email);
|
||||
} else if (query._id) {
|
||||
user = await couchdbService.findUserById(query._id);
|
||||
} else {
|
||||
// Generic query fallback
|
||||
const docs = await couchdbService.find({
|
||||
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
|
||||
else if (query._id) { user = await couchdeService.findUserById(query._id); }
|
||||
else { // Generic query fallback
|
||||
const docs = await couchdeService.find({
|
||||
selector: { type: "user", ...query },
|
||||
limit: 1
|
||||
});
|
||||
@@ -74,7 +89,7 @@ class User {
|
||||
const errorContext = createErrorContext('User', 'findById', { id });
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
const user = await couchdeService.findUserById(id);
|
||||
return user ? new User(user) : null;
|
||||
}, errorContext);
|
||||
}
|
||||
@@ -83,16 +98,14 @@ class User {
|
||||
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
const user = await couchdeService.findUserById(id);
|
||||
if (!user) return null;
|
||||
|
||||
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
|
||||
const saved = await couchdbService.update(id, updatedUser);
|
||||
const saved = await couchdeService.update(id, updatedUser);
|
||||
|
||||
if (options.new) {
|
||||
return saved;
|
||||
}
|
||||
return user;
|
||||
if (options.new) { return new User(saved); }
|
||||
return new User(user);
|
||||
}, errorContext);
|
||||
}
|
||||
|
||||
@@ -100,7 +113,7 @@ class User {
|
||||
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
const user = await couchdeService.findUserById(id);
|
||||
if (!user) return null;
|
||||
|
||||
await couchdbService.delete(id);
|
||||
@@ -113,7 +126,8 @@ class User {
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
const selector = { type: "user", ...query };
|
||||
return await couchdbService.find({ selector });
|
||||
const users = await couchdbService.find({ selector });
|
||||
return users.map(u => new User(u));
|
||||
}, errorContext);
|
||||
}
|
||||
|
||||
@@ -123,23 +137,15 @@ class User {
|
||||
return await withErrorHandling(async () => {
|
||||
const user = new User(userData);
|
||||
|
||||
// Hash password if provided
|
||||
if (user.password) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(user.password, salt);
|
||||
}
|
||||
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
|
||||
|
||||
// Generate ID if not provided
|
||||
if (!user._id) {
|
||||
user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
if (!user._id) { user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }
|
||||
|
||||
const created = await couchdbService.createDocument(user.toJSON());
|
||||
return new User(created);
|
||||
}, errorContext);
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
async save() {
|
||||
const errorContext = createErrorContext('User', 'save', {
|
||||
id: this._id,
|
||||
@@ -148,23 +154,18 @@ class User {
|
||||
});
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
this.updatedAt = new Date().toISOString();
|
||||
if (!this._id) {
|
||||
// New document
|
||||
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Hash password if not already hashed
|
||||
if (this.password && !this.password.startsWith('$2')) {
|
||||
if (this.password && !this.password.startsWith('$2)')) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
}
|
||||
|
||||
const created = await couchdbService.createDocument(this.toJSON());
|
||||
this._rev = created._rev;
|
||||
return this;
|
||||
} else {
|
||||
// Update existing document
|
||||
this.updatedAt = new Date().toISOString();
|
||||
const updated = await couchdbService.updateDocument(this.toJSON());
|
||||
const updated = await couchdeService.updateDocument(this.toJSON());
|
||||
this._rev = updated._rev;
|
||||
return this;
|
||||
}
|
||||
@@ -182,14 +183,12 @@ class User {
|
||||
}, errorContext);
|
||||
}
|
||||
|
||||
// Helper method to get user without password
|
||||
toSafeObject() {
|
||||
const obj = this.toJSON();
|
||||
delete obj.password;
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Convert to CouchDB document format
|
||||
toJSON() {
|
||||
return {
|
||||
_id: this._id,
|
||||
@@ -198,58 +197,26 @@ class User {
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
avatar: this.avatar,
|
||||
cloudinaryPublicId: this.cloudinaryPublicId,
|
||||
bio: this.bio,
|
||||
location: this.location,
|
||||
website: this.website,
|
||||
social: this.social,
|
||||
privacySettings: this.privacySettings,
|
||||
preferences: this.preferences,
|
||||
isPremium: this.isPremium,
|
||||
points: this.points,
|
||||
adoptedStreets: this.adoptedStreets,
|
||||
completedTasks: this.completedTasks,
|
||||
posts: this.posts,
|
||||
events: this.events,
|
||||
profilePicture: this.profilePicture,
|
||||
cloudinaryPublicId: this.cloudinaryPublicId,
|
||||
earnedBadges: this.earnedBadges,
|
||||
stats: this.stats,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Static method for select functionality
|
||||
static async select(fields) {
|
||||
const errorContext = createErrorContext('User', 'select', { fields });
|
||||
|
||||
return await withErrorHandling(async () => {
|
||||
const users = await couchdbService.find({
|
||||
selector: { type: "user" },
|
||||
fields: fields
|
||||
});
|
||||
return users.map(user => new User(user));
|
||||
}, errorContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Add select method to instance for chaining
|
||||
User.prototype.select = function(fields) {
|
||||
const obj = this.toJSON();
|
||||
const selected = {};
|
||||
|
||||
if (fields.includes('-password')) {
|
||||
// Exclude password
|
||||
fields = fields.filter(f => f !== '-password');
|
||||
fields.forEach(field => {
|
||||
if (obj[field] !== undefined) {
|
||||
selected[field] = obj[field];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Include only specified fields
|
||||
fields.forEach(field => {
|
||||
if (obj[field] !== undefined) {
|
||||
selected[field] = obj[field];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return selected;
|
||||
};
|
||||
|
||||
module.exports = User;
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
const express = require("express");
|
||||
const auth = require("../middleware/auth");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Parse timeframe parameter to date filter
|
||||
*/
|
||||
const getTimeframeFilter = (timeframe = "all") => {
|
||||
const now = new Date();
|
||||
let startDate = null;
|
||||
|
||||
switch (timeframe) {
|
||||
case "7d":
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "30d":
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "90d":
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "all":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return startDate ? startDate.toISOString() : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group data by time period (day, week, month)
|
||||
*/
|
||||
const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => {
|
||||
const grouped = {};
|
||||
|
||||
data.forEach((item) => {
|
||||
const date = new Date(item[dateField]);
|
||||
let key;
|
||||
|
||||
switch (groupBy) {
|
||||
case "week":
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
key = weekStart.toISOString().split("T")[0];
|
||||
break;
|
||||
case "month":
|
||||
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
break;
|
||||
case "day":
|
||||
default:
|
||||
key = date.toISOString().split("T")[0];
|
||||
break;
|
||||
}
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(item);
|
||||
});
|
||||
|
||||
return Object.keys(grouped)
|
||||
.sort()
|
||||
.map((key) => ({
|
||||
period: key,
|
||||
count: grouped[key].length,
|
||||
items: grouped[key],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/analytics/overview
|
||||
* Get overall platform statistics
|
||||
*/
|
||||
router.get(
|
||||
"/overview",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Build queries
|
||||
const userQuery = { selector: { type: "user" } };
|
||||
const streetQuery = { selector: { type: "street" } };
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
const eventQuery = { selector: { type: "event" } };
|
||||
const postQuery = { selector: { type: "post" } };
|
||||
|
||||
// Add timeframe filters if specified
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [users, streets, tasks, events, posts] = await Promise.all([
|
||||
couchdbService.find(userQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(postQuery),
|
||||
]);
|
||||
|
||||
// Calculate statistics
|
||||
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
|
||||
const completedTasks = tasks.filter((t) => t.status === "completed").length;
|
||||
const activeEvents = events.filter((e) => e.status === "upcoming").length;
|
||||
const totalPoints = users.reduce((sum, user) => sum + (user.points || 0), 0);
|
||||
const averagePointsPerUser = users.length > 0 ? Math.round(totalPoints / users.length) : 0;
|
||||
|
||||
res.json({
|
||||
overview: {
|
||||
totalUsers: users.length,
|
||||
totalStreets: streets.length,
|
||||
adoptedStreets,
|
||||
availableStreets: streets.length - adoptedStreets,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks,
|
||||
pendingTasks: tasks.length - completedTasks,
|
||||
totalEvents: events.length,
|
||||
activeEvents,
|
||||
completedEvents: events.filter((e) => e.status === "completed").length,
|
||||
totalPosts: posts.length,
|
||||
totalPoints,
|
||||
averagePointsPerUser,
|
||||
},
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/user/:userId
|
||||
* Get user-specific analytics
|
||||
*/
|
||||
router.get(
|
||||
"/user/:userId",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get user
|
||||
const user = await couchdbService.findUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
// Build queries for user's activity
|
||||
const taskQuery = {
|
||||
selector: {
|
||||
type: "task",
|
||||
"completedBy.userId": userId,
|
||||
},
|
||||
};
|
||||
const postQuery = {
|
||||
selector: {
|
||||
type: "post",
|
||||
"user.userId": userId,
|
||||
},
|
||||
};
|
||||
const eventQuery = {
|
||||
selector: {
|
||||
type: "event",
|
||||
participants: {
|
||||
$elemMatch: { userId: userId },
|
||||
},
|
||||
},
|
||||
};
|
||||
const transactionQuery = {
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
"user.userId": userId,
|
||||
},
|
||||
};
|
||||
|
||||
// Add timeframe filters if specified
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
transactionQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [tasks, posts, events, transactions] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(transactionQuery),
|
||||
]);
|
||||
|
||||
// Get adopted streets
|
||||
const adoptedStreetsDetails = await Promise.all(
|
||||
(user.adoptedStreets || []).map((streetId) => couchdbService.getDocument(streetId)),
|
||||
);
|
||||
|
||||
// Calculate points earned/spent
|
||||
const pointsEarned = transactions
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const pointsSpent = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
|
||||
// Calculate engagement metrics
|
||||
const totalLikesReceived = posts.reduce((sum, post) => sum + (post.likesCount || 0), 0);
|
||||
const totalCommentsReceived = posts.reduce((sum, post) => sum + (post.commentsCount || 0), 0);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
points: user.points || 0,
|
||||
isPremium: user.isPremium || false,
|
||||
},
|
||||
stats: {
|
||||
streetsAdopted: adoptedStreetsDetails.filter(Boolean).length,
|
||||
tasksCompleted: tasks.length,
|
||||
postsCreated: posts.length,
|
||||
eventsParticipated: events.length,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
pointsEarned,
|
||||
pointsSpent,
|
||||
totalLikesReceived,
|
||||
totalCommentsReceived,
|
||||
},
|
||||
recentActivity: {
|
||||
tasks: tasks.slice(0, 5),
|
||||
posts: posts.slice(0, 5),
|
||||
events: events.slice(0, 5),
|
||||
},
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/activity
|
||||
* Get activity over time
|
||||
*/
|
||||
router.get(
|
||||
"/activity",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "30d", groupBy = "day" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Build queries
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
const postQuery = { selector: { type: "post" } };
|
||||
const eventQuery = { selector: { type: "event" } };
|
||||
const streetQuery = { selector: { type: "street", status: "adopted" } };
|
||||
|
||||
// Add timeframe filters
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
postQuery.selector.createdAt = { $gte: startDate };
|
||||
eventQuery.selector.createdAt = { $gte: startDate };
|
||||
streetQuery.selector["adoptedBy.userId"] = { $exists: true };
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [tasks, posts, events, streets] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(eventQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
]);
|
||||
|
||||
// Filter by timeframe
|
||||
const filterByTimeframe = (items) => {
|
||||
if (!startDate) return items;
|
||||
return items.filter((item) => {
|
||||
const itemDate = new Date(item.createdAt);
|
||||
return itemDate >= new Date(startDate);
|
||||
});
|
||||
};
|
||||
|
||||
const filteredTasks = filterByTimeframe(tasks);
|
||||
const filteredPosts = filterByTimeframe(posts);
|
||||
const filteredEvents = filterByTimeframe(events);
|
||||
const filteredStreets = filterByTimeframe(streets);
|
||||
|
||||
// Group by time period
|
||||
const groupedTasks = groupByTimePeriod(filteredTasks, groupBy);
|
||||
const groupedPosts = groupByTimePeriod(filteredPosts, groupBy);
|
||||
const groupedEvents = groupByTimePeriod(filteredEvents, groupBy);
|
||||
const groupedStreets = groupByTimePeriod(filteredStreets, groupBy);
|
||||
|
||||
// Combine all periods
|
||||
const allPeriods = new Set([
|
||||
...groupedTasks.map((g) => g.period),
|
||||
...groupedPosts.map((g) => g.period),
|
||||
...groupedEvents.map((g) => g.period),
|
||||
...groupedStreets.map((g) => g.period),
|
||||
]);
|
||||
|
||||
const activityData = Array.from(allPeriods)
|
||||
.sort()
|
||||
.map((period) => ({
|
||||
period,
|
||||
tasks: groupedTasks.find((g) => g.period === period)?.count || 0,
|
||||
posts: groupedPosts.find((g) => g.period === period)?.count || 0,
|
||||
events: groupedEvents.find((g) => g.period === period)?.count || 0,
|
||||
streetsAdopted: groupedStreets.find((g) => g.period === period)?.count || 0,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
activity: activityData,
|
||||
timeframe,
|
||||
groupBy,
|
||||
summary: {
|
||||
totalTasks: filteredTasks.length,
|
||||
totalPosts: filteredPosts.length,
|
||||
totalEvents: filteredEvents.length,
|
||||
totalStreetsAdopted: filteredStreets.length,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/top-contributors
|
||||
* Get top contributing users
|
||||
*/
|
||||
router.get(
|
||||
"/top-contributors",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get all users
|
||||
const users = await couchdbService.find({
|
||||
selector: { type: "user" },
|
||||
});
|
||||
|
||||
// If timeframe is specified, calculate contributions within that timeframe
|
||||
let contributors;
|
||||
|
||||
if (startDate && metric !== "points") {
|
||||
// For time-based metrics, query activities
|
||||
const contributorsWithActivity = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const taskQuery = {
|
||||
selector: {
|
||||
type: "task",
|
||||
"completedBy.userId": user._id,
|
||||
createdAt: { $gte: startDate },
|
||||
},
|
||||
};
|
||||
const postQuery = {
|
||||
selector: {
|
||||
type: "post",
|
||||
"user.userId": user._id,
|
||||
createdAt: { $gte: startDate },
|
||||
},
|
||||
};
|
||||
const streetQuery = {
|
||||
selector: {
|
||||
type: "street",
|
||||
"adoptedBy.userId": user._id,
|
||||
},
|
||||
};
|
||||
|
||||
const [tasks, posts, streets] = await Promise.all([
|
||||
couchdbService.find(taskQuery),
|
||||
couchdbService.find(postQuery),
|
||||
couchdbService.find(streetQuery),
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
switch (metric) {
|
||||
case "tasks":
|
||||
score = tasks.length;
|
||||
break;
|
||||
case "posts":
|
||||
score = posts.length;
|
||||
break;
|
||||
case "streets":
|
||||
score = streets.length;
|
||||
break;
|
||||
default:
|
||||
score = user.points || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profilePicture: user.profilePicture,
|
||||
isPremium: user.isPremium,
|
||||
score,
|
||||
stats: {
|
||||
points: user.points || 0,
|
||||
tasksCompleted: tasks.length,
|
||||
postsCreated: posts.length,
|
||||
streetsAdopted: streets.length,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
contributors = contributorsWithActivity
|
||||
.filter((c) => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, parseInt(limit));
|
||||
} else {
|
||||
// For all-time or points metric, use user data directly
|
||||
contributors = users
|
||||
.map((user) => {
|
||||
let score = 0;
|
||||
switch (metric) {
|
||||
case "tasks":
|
||||
score = user.stats?.tasksCompleted || 0;
|
||||
break;
|
||||
case "posts":
|
||||
score = user.stats?.postsCreated || 0;
|
||||
break;
|
||||
case "streets":
|
||||
score = user.stats?.streetsAdopted || 0;
|
||||
break;
|
||||
default:
|
||||
score = user.points || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
profilePicture: user.profilePicture,
|
||||
isPremium: user.isPremium,
|
||||
score,
|
||||
stats: {
|
||||
points: user.points || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || 0,
|
||||
postsCreated: user.stats?.postsCreated || 0,
|
||||
streetsAdopted: user.stats?.streetsAdopted || 0,
|
||||
badgesEarned: (user.earnedBadges || []).length,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((c) => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, parseInt(limit));
|
||||
}
|
||||
|
||||
res.json({
|
||||
contributors,
|
||||
metric,
|
||||
timeframe,
|
||||
limit: parseInt(limit),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/street-stats
|
||||
* Get street adoption and task completion statistics
|
||||
*/
|
||||
router.get(
|
||||
"/street-stats",
|
||||
auth,
|
||||
getCacheMiddleware(300), // Cache for 5 minutes
|
||||
asyncHandler(async (req, res) => {
|
||||
const { timeframe = "all" } = req.query;
|
||||
const startDate = getTimeframeFilter(timeframe);
|
||||
|
||||
// Get all streets
|
||||
const streets = await couchdbService.find({
|
||||
selector: { type: "street" },
|
||||
});
|
||||
|
||||
// Get all tasks
|
||||
const taskQuery = { selector: { type: "task" } };
|
||||
if (startDate) {
|
||||
taskQuery.selector.createdAt = { $gte: startDate };
|
||||
}
|
||||
const tasks = await couchdbService.find(taskQuery);
|
||||
|
||||
// Calculate street statistics
|
||||
const totalStreets = streets.length;
|
||||
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
|
||||
const availableStreets = streets.filter((s) => s.status === "available").length;
|
||||
|
||||
const adoptionRate = totalStreets > 0 ? ((adoptedStreets / totalStreets) * 100).toFixed(2) : 0;
|
||||
|
||||
// Task statistics
|
||||
const totalTasks = tasks.length;
|
||||
const completedTasks = tasks.filter((t) => t.status === "completed").length;
|
||||
const pendingTasks = tasks.filter((t) => t.status === "pending").length;
|
||||
const inProgressTasks = tasks.filter((t) => t.status === "in_progress").length;
|
||||
|
||||
const completionRate = totalTasks > 0 ? ((completedTasks / totalTasks) * 100).toFixed(2) : 0;
|
||||
|
||||
// Top streets by task completion
|
||||
const streetTaskCounts = {};
|
||||
tasks
|
||||
.filter((t) => t.status === "completed" && t.street?.streetId)
|
||||
.forEach((task) => {
|
||||
const streetId = task.street.streetId;
|
||||
if (!streetTaskCounts[streetId]) {
|
||||
streetTaskCounts[streetId] = {
|
||||
streetId,
|
||||
streetName: task.street.name,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
streetTaskCounts[streetId].count++;
|
||||
});
|
||||
|
||||
const topStreets = Object.values(streetTaskCounts)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
res.json({
|
||||
adoption: {
|
||||
totalStreets,
|
||||
adoptedStreets,
|
||||
availableStreets,
|
||||
adoptionRate: parseFloat(adoptionRate),
|
||||
},
|
||||
tasks: {
|
||||
totalTasks,
|
||||
completedTasks,
|
||||
pendingTasks,
|
||||
inProgressTasks,
|
||||
completionRate: parseFloat(completionRate),
|
||||
},
|
||||
topStreets,
|
||||
timeframe,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,6 +4,7 @@ const UserBadge = require("../models/UserBadge");
|
||||
const auth = require("../middleware/auth");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { getUserBadgeProgress } = require("../services/gamificationService");
|
||||
const { getCacheMiddleware } = require("../middleware/cache");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,8 +14,9 @@ const router = express.Router();
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
getCacheMiddleware(600), // 10 minute cache
|
||||
asyncHandler(async (req, res) => {
|
||||
const badges = await Badge.find({ type: "badge" });
|
||||
const badges = await Badge.findAll();
|
||||
// Sort by order and rarity in JavaScript since CouchDB doesn't support complex sorting
|
||||
badges.sort((a, b) => {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
@@ -31,6 +33,7 @@ router.get(
|
||||
router.get(
|
||||
"/progress",
|
||||
auth,
|
||||
getCacheMiddleware(600), // 10 minute cache
|
||||
asyncHandler(async (req, res) => {
|
||||
const progress = await getUserBadgeProgress(req.user.id);
|
||||
res.json(progress);
|
||||
@@ -38,11 +41,12 @@ router.get(
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/badges/users/:userId
|
||||
* Get badges earned by a specific user
|
||||
* GET /api/users/:userId/badges
|
||||
* Get badges earned by a specific user with progress
|
||||
*/
|
||||
router.get(
|
||||
"/users/:userId",
|
||||
getCacheMiddleware(600), // 10 minute cache
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
@@ -67,6 +71,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
"/:badgeId",
|
||||
getCacheMiddleware(600), // 10 minute cache
|
||||
asyncHandler(async (req, res) => {
|
||||
const { badgeId } = req.params;
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const auth = require("../middleware/auth");
|
||||
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
|
||||
const gamificationService = require("../services/gamificationService");
|
||||
const User = require("../models/User");
|
||||
const logger = require("../utils/logger");
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/global
|
||||
* @desc Get global leaderboard (all time)
|
||||
* @access Public
|
||||
* @query limit (default 100), offset (default 0)
|
||||
*/
|
||||
router.get("/global", getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
logger.info("Fetching global leaderboard", { limit, offset });
|
||||
|
||||
const leaderboard = await gamificationService.getGlobalLeaderboard(limit, offset);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
limit,
|
||||
offset,
|
||||
data: leaderboard
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching global leaderboard", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching global leaderboard",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/weekly
|
||||
* @desc Get weekly leaderboard
|
||||
* @access Public
|
||||
* @query limit (default 100), offset (default 0)
|
||||
*/
|
||||
router.get("/weekly", getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
logger.info("Fetching weekly leaderboard", { limit, offset });
|
||||
|
||||
const leaderboard = await gamificationService.getWeeklyLeaderboard(limit, offset);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
limit,
|
||||
offset,
|
||||
timeframe: "week",
|
||||
data: leaderboard
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching weekly leaderboard", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching weekly leaderboard",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/monthly
|
||||
* @desc Get monthly leaderboard
|
||||
* @access Public
|
||||
* @query limit (default 100), offset (default 0)
|
||||
*/
|
||||
router.get("/monthly", getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
logger.info("Fetching monthly leaderboard", { limit, offset });
|
||||
|
||||
const leaderboard = await gamificationService.getMonthlyLeaderboard(limit, offset);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
limit,
|
||||
offset,
|
||||
timeframe: "month",
|
||||
data: leaderboard
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching monthly leaderboard", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching monthly leaderboard",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/friends
|
||||
* @desc Get friends leaderboard (requires auth)
|
||||
* @access Private
|
||||
* @query limit (default 100), offset (default 0)
|
||||
*/
|
||||
router.get("/friends", auth, getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const userId = req.user.id;
|
||||
|
||||
logger.info("Fetching friends leaderboard", { userId, limit, offset });
|
||||
|
||||
const leaderboard = await gamificationService.getFriendsLeaderboard(userId, limit, offset);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
limit,
|
||||
offset,
|
||||
data: leaderboard
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching friends leaderboard", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching friends leaderboard",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/user/:userId
|
||||
* @desc Get user's rank and position in leaderboard
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/user/:userId", getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const timeframe = req.query.timeframe || "all"; // all, week, month
|
||||
|
||||
logger.info("Fetching user leaderboard position", { userId, timeframe });
|
||||
|
||||
const userPosition = await gamificationService.getUserLeaderboardPosition(userId, timeframe);
|
||||
|
||||
if (!userPosition) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
msg: "User not found or has no points"
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: userPosition
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching user leaderboard position", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching user position",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /api/leaderboard/stats
|
||||
* @desc Get leaderboard statistics
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/stats", getCacheMiddleware(300), async (req, res) => {
|
||||
try {
|
||||
logger.info("Fetching leaderboard statistics");
|
||||
|
||||
const stats = await gamificationService.getLeaderboardStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching leaderboard statistics", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error fetching leaderboard statistics",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,126 @@
|
||||
const express = require("express");
|
||||
const User = require("../models/User");
|
||||
const auth = require("../middleware/auth");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { upload, handleUploadError } = require("../middleware/upload");
|
||||
const { uploadImage, deleteImage } = require("../config/cloudinary");
|
||||
const { validateProfile } = require("../middleware/validators/profileValidator");
|
||||
const { userIdValidation } = require("../middleware/validators/userValidator");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET user profile
|
||||
router.get(
|
||||
"/:userId",
|
||||
auth,
|
||||
userIdValidation,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
if (user.privacySettings.profileVisibility === "private" && req.user.id !== userId) {
|
||||
return res.status(403).json({ msg: "This profile is private" });
|
||||
}
|
||||
|
||||
res.json(user.toSafeObject());
|
||||
})
|
||||
);
|
||||
|
||||
// PUT update user profile
|
||||
router.put(
|
||||
"/",
|
||||
auth,
|
||||
validateProfile,
|
||||
asyncHandler(async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const {
|
||||
bio,
|
||||
location,
|
||||
website,
|
||||
social,
|
||||
privacySettings,
|
||||
preferences,
|
||||
} = req.body;
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (bio !== undefined) user.bio = bio;
|
||||
if (location !== undefined) user.location = location;
|
||||
if (website !== undefined) user.website = website;
|
||||
if (social !== undefined) user.social = { ...user.social, ...social };
|
||||
if (privacySettings !== undefined) user.privacySettings = { ...user.privacySettings, ...privacySettings };
|
||||
if (preferences !== undefined) user.preferences = { ...user.preferences, ...preferences };
|
||||
|
||||
const updatedUser = await user.save();
|
||||
|
||||
res.json(updatedUser.toSafeObject());
|
||||
})
|
||||
);
|
||||
|
||||
// POST upload avatar
|
||||
router.post(
|
||||
"/avatar",
|
||||
auth,
|
||||
upload.single("avatar"),
|
||||
handleUploadError,
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ msg: "No image file provided" });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
if (user.cloudinaryPublicId) {
|
||||
await deleteImage(user.cloudinaryPublicId);
|
||||
}
|
||||
|
||||
const result = await uploadImage(
|
||||
req.file.buffer,
|
||||
"adopt-a-street/avatars"
|
||||
);
|
||||
|
||||
user.avatar = result.secure_url;
|
||||
user.cloudinaryPublicId = result.public_id;
|
||||
const updatedUser = await user.save();
|
||||
|
||||
res.json({
|
||||
msg: "Avatar updated successfully",
|
||||
avatar: updatedUser.avatar
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// DELETE remove avatar
|
||||
router.delete(
|
||||
"/avatar",
|
||||
auth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
if (user.cloudinaryPublicId) {
|
||||
await deleteImage(user.cloudinaryPublicId);
|
||||
user.avatar = null;
|
||||
user.cloudinaryPublicId = null;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
res.json({ msg: "Avatar removed successfully" });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+1
-70
@@ -4,8 +4,6 @@ const Street = require("../models/Street");
|
||||
const auth = require("../middleware/auth");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { userIdValidation } = require("../middleware/validators/userValidator");
|
||||
const { upload, handleUploadError } = require("../middleware/upload");
|
||||
const { uploadImage, deleteImage } = require("../config/cloudinary");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -38,7 +36,7 @@ router.get(
|
||||
}
|
||||
|
||||
const userWithStreets = {
|
||||
...user,
|
||||
...user.toSafeObject(),
|
||||
adoptedStreets,
|
||||
};
|
||||
|
||||
@@ -46,71 +44,4 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
// Upload profile picture
|
||||
router.post(
|
||||
"/profile-picture",
|
||||
auth,
|
||||
upload.single("image"),
|
||||
handleUploadError,
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ msg: "No image file provided" });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
// Delete old profile picture if exists
|
||||
if (user.cloudinaryPublicId) {
|
||||
await deleteImage(user.cloudinaryPublicId);
|
||||
}
|
||||
|
||||
// Upload new image to Cloudinary
|
||||
const result = await uploadImage(
|
||||
req.file.buffer,
|
||||
"adopt-a-street/profiles",
|
||||
);
|
||||
|
||||
// Update user with new profile picture
|
||||
const updatedUser = await User.update(req.user.id, {
|
||||
profilePicture: result.url,
|
||||
cloudinaryPublicId: result.publicId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
msg: "Profile picture updated successfully",
|
||||
profilePicture: updatedUser.profilePicture,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Delete profile picture
|
||||
router.delete(
|
||||
"/profile-picture",
|
||||
auth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ msg: "User not found" });
|
||||
}
|
||||
|
||||
if (!user.cloudinaryPublicId) {
|
||||
return res.status(400).json({ msg: "No profile picture to delete" });
|
||||
}
|
||||
|
||||
// Delete image from Cloudinary
|
||||
await deleteImage(user.cloudinaryPublicId);
|
||||
|
||||
// Remove from user
|
||||
await User.update(req.user.id, {
|
||||
profilePicture: undefined,
|
||||
cloudinaryPublicId: undefined,
|
||||
});
|
||||
|
||||
res.json({ msg: "Profile picture deleted successfully" });
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -137,6 +137,7 @@ const aiRoutes = require("./routes/ai");
|
||||
const paymentRoutes = require("./routes/payments");
|
||||
const userRoutes = require("./routes/users");
|
||||
const cacheRoutes = require("./routes/cache");
|
||||
const profileRoutes = require("./routes/profile");
|
||||
|
||||
// Apply rate limiters
|
||||
app.use("/api/auth/register", authLimiter);
|
||||
@@ -238,6 +239,8 @@ app.use("/api/ai", aiRoutes);
|
||||
app.use("/api/payments", paymentRoutes);
|
||||
app.use("/api/users", userRoutes);
|
||||
app.use("/api/cache", cacheRoutes);
|
||||
app.use("/api/analytics", analyticsRoutes);
|
||||
app.use("/api/leaderboard", leaderboardRoutes);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.send("Street Adoption App Backend");
|
||||
|
||||
@@ -113,14 +113,14 @@ async function checkAndAwardBadges(userId, userPoints = null) {
|
||||
// Check each badge criteria
|
||||
for (const badge of allBadges) {
|
||||
// Skip if user already has this badge
|
||||
if (userBadges.some(ub => ub.badgeId === badge._id)) {
|
||||
if (userBadges.some(ub => ub.badge?._id === badge._id || ub.badge === badge._id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let qualifies = false;
|
||||
|
||||
// Check different badge criteria
|
||||
switch (badge.criteria.type) {
|
||||
switch (badge.criteria?.type) {
|
||||
case 'points_earned':
|
||||
qualifies = userPoints >= badge.criteria.threshold;
|
||||
break;
|
||||
@@ -128,13 +128,13 @@ async function checkAndAwardBadges(userId, userPoints = null) {
|
||||
qualifies = userStats.streetAdoptions >= badge.criteria.threshold;
|
||||
break;
|
||||
case 'task_completions':
|
||||
qualifies = userStats.taskCompletions >= badge.criteria.threshold;
|
||||
qualifies = userStats.tasksCompleted >= badge.criteria.threshold;
|
||||
break;
|
||||
case 'post_creations':
|
||||
qualifies = userStats.postCreations >= badge.criteria.threshold;
|
||||
qualifies = userStats.postsCreated >= badge.criteria.threshold;
|
||||
break;
|
||||
case 'event_participations':
|
||||
qualifies = userStats.eventParticipations >= badge.criteria.threshold;
|
||||
qualifies = userStats.eventsParticipated >= badge.criteria.threshold;
|
||||
break;
|
||||
case 'consecutive_days':
|
||||
qualifies = userStats.consecutiveDays >= badge.criteria.threshold;
|
||||
@@ -168,9 +168,9 @@ async function awardBadge(userId, badgeId) {
|
||||
|
||||
// Create user badge record
|
||||
const userBadge = await UserBadge.create({
|
||||
userId: userId,
|
||||
badgeId: badgeId,
|
||||
awardedAt: new Date().toISOString(),
|
||||
user: userId,
|
||||
badge: badgeId,
|
||||
earnedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Award points for earning badge (if it's a rare or higher badge)
|
||||
@@ -205,16 +205,19 @@ async function awardBadge(userId, badgeId) {
|
||||
*/
|
||||
async function getUserStats(userId) {
|
||||
try {
|
||||
// This would typically involve querying various collections
|
||||
// For now, return basic stats - this should be enhanced
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return {
|
||||
streetAdoptions: 0, // Would query Street collection
|
||||
taskCompletions: 0, // Would query Task collection
|
||||
postCreations: 0, // Would query Post collection
|
||||
eventParticipations: 0, // Would query Event participation
|
||||
consecutiveDays: 0, // Would calculate from login history
|
||||
points: user.points || 0,
|
||||
streetsAdopted: user.stats?.streetsAdopted || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || 0,
|
||||
postsCreated: user.stats?.postsCreated || 0,
|
||||
eventsParticipated: user.stats?.eventsParticipated || 0,
|
||||
badgesEarned: user.stats?.badgesEarned || 0,
|
||||
consecutiveDays: user.stats?.consecutiveDays || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting user stats:", error);
|
||||
@@ -222,6 +225,71 @@ async function getUserStats(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's badge progress for all badges (earned and unearned)
|
||||
*/
|
||||
async function getUserBadgeProgress(userId) {
|
||||
try {
|
||||
const allBadges = await Badge.findAll();
|
||||
const userStats = await getUserStats(userId);
|
||||
const userEarnedBadges = await UserBadge.findByUser(userId);
|
||||
|
||||
const badgeProgress = allBadges.map(badge => {
|
||||
const earnedBadge = userEarnedBadges.find(ub => ub.badge?._id === badge._id || ub.badge === badge._id);
|
||||
const isEarned = !!earnedBadge;
|
||||
let progress = 0;
|
||||
let threshold = badge.criteria?.threshold || 0;
|
||||
|
||||
if (isEarned) {
|
||||
progress = threshold; // If earned, progress is full
|
||||
} else if (badge.criteria?.type) {
|
||||
switch (badge.criteria.type) {
|
||||
case 'points_earned':
|
||||
progress = userStats.points || 0;
|
||||
break;
|
||||
case 'street_adoptions':
|
||||
progress = userStats.streetsAdopted;
|
||||
break;
|
||||
case 'task_completions':
|
||||
progress = userStats.tasksCompleted;
|
||||
break;
|
||||
case 'post_creations':
|
||||
progress = userStats.postsCreated;
|
||||
break;
|
||||
case 'event_participations':
|
||||
progress = userStats.eventsParticipated;
|
||||
break;
|
||||
case 'consecutive_days':
|
||||
progress = userStats.consecutiveDays;
|
||||
break;
|
||||
case 'special':
|
||||
progress = 0; // Special badges have no progress bar
|
||||
threshold = 1;
|
||||
break;
|
||||
default:
|
||||
progress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure progress doesn't exceed threshold
|
||||
progress = Math.min(progress, threshold);
|
||||
|
||||
return {
|
||||
...badge,
|
||||
isEarned,
|
||||
progress,
|
||||
threshold,
|
||||
earnedAt: isEarned ? earnedBadge.earnedAt : null,
|
||||
};
|
||||
});
|
||||
|
||||
return badgeProgress;
|
||||
} catch (error) {
|
||||
console.error("Error getting user badge progress:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's badges
|
||||
*/
|
||||
@@ -231,11 +299,13 @@ async function getUserBadges(userId) {
|
||||
const badges = [];
|
||||
|
||||
for (const userBadge of userBadges) {
|
||||
const badge = await Badge.findById(userBadge.badgeId);
|
||||
const badgeData = userBadge.badge;
|
||||
// If badge is already populated (object), use it; otherwise fetch it
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
if (badge) {
|
||||
badges.push({
|
||||
...badge,
|
||||
awardedAt: userBadge.awardedAt,
|
||||
earnedAt: userBadge.earnedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -301,6 +371,455 @@ async function getLeaderboard(limit = 10) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global leaderboard (all time)
|
||||
* @param {number} limit - Number of users to return
|
||||
* @param {number} offset - Offset for pagination
|
||||
* @returns {Promise<Array>} Leaderboard data
|
||||
*/
|
||||
async function getGlobalLeaderboard(limit = 100, offset = 0) {
|
||||
try {
|
||||
const couchdbService = require("./couchdbService");
|
||||
const Street = require("../models/Street");
|
||||
const Task = require("../models/Task");
|
||||
|
||||
// Get all users sorted by points
|
||||
const result = await couchdbService.find({
|
||||
selector: {
|
||||
type: "user",
|
||||
points: { $gt: 0 }
|
||||
},
|
||||
sort: [{ points: "desc" }],
|
||||
limit: limit,
|
||||
skip: offset
|
||||
});
|
||||
|
||||
// Enrich with stats and badges
|
||||
const leaderboard = await Promise.all(result.map(async (user, index) => {
|
||||
// Get user badges
|
||||
const userBadges = await UserBadge.findByUser(user._id);
|
||||
const badges = await Promise.all(userBadges.map(async (ub) => {
|
||||
const badgeData = ub.badge;
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
return badge ? {
|
||||
_id: badge._id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
rarity: badge.rarity
|
||||
} : null;
|
||||
}));
|
||||
|
||||
return {
|
||||
rank: offset + index + 1,
|
||||
userId: user._id,
|
||||
username: user.name,
|
||||
avatar: user.profilePicture || null,
|
||||
points: user.points || 0,
|
||||
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
|
||||
badges: badges.filter(b => b !== null)
|
||||
};
|
||||
}));
|
||||
|
||||
return leaderboard;
|
||||
} catch (error) {
|
||||
console.error("Error getting global leaderboard:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekly leaderboard
|
||||
* @param {number} limit - Number of users to return
|
||||
* @param {number} offset - Offset for pagination
|
||||
* @returns {Promise<Array>} Leaderboard data
|
||||
*/
|
||||
async function getWeeklyLeaderboard(limit = 100, offset = 0) {
|
||||
try {
|
||||
const couchdbService = require("./couchdbService");
|
||||
|
||||
// Calculate start of week (Monday 00:00:00)
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - daysToMonday);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
// Get all point transactions since start of week
|
||||
const transactions = await couchdbService.find({
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
createdAt: { $gte: startOfWeek.toISOString() }
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate points by user
|
||||
const userPointsMap = {};
|
||||
transactions.forEach(transaction => {
|
||||
if (!userPointsMap[transaction.user]) {
|
||||
userPointsMap[transaction.user] = 0;
|
||||
}
|
||||
userPointsMap[transaction.user] += transaction.amount;
|
||||
});
|
||||
|
||||
// Convert to array and sort
|
||||
const userPoints = Object.entries(userPointsMap)
|
||||
.map(([userId, points]) => ({ userId, points }))
|
||||
.filter(entry => entry.points > 0)
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(offset, offset + limit);
|
||||
|
||||
// Enrich with user data
|
||||
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
|
||||
const user = await User.findById(entry.userId);
|
||||
if (!user) return null;
|
||||
|
||||
// Get user badges
|
||||
const userBadges = await UserBadge.findByUser(userId);
|
||||
const badges = await Promise.all(userBadges.map(async (ub) => {
|
||||
const badgeData = ub.badge;
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
return badge ? {
|
||||
_id: badge._id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
rarity: badge.rarity
|
||||
} : null;
|
||||
}));
|
||||
|
||||
return {
|
||||
rank: offset + index + 1,
|
||||
userId: user._id,
|
||||
username: user.name,
|
||||
avatar: user.profilePicture || null,
|
||||
points: entry.points,
|
||||
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
|
||||
badges: badges.filter(b => b !== null)
|
||||
};
|
||||
}));
|
||||
|
||||
return leaderboard.filter(entry => entry !== null);
|
||||
} catch (error) {
|
||||
console.error("Error getting weekly leaderboard:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly leaderboard
|
||||
* @param {number} limit - Number of users to return
|
||||
* @param {number} offset - Offset for pagination
|
||||
* @returns {Promise<Array>} Leaderboard data
|
||||
*/
|
||||
async function getMonthlyLeaderboard(limit = 100, offset = 0) {
|
||||
try {
|
||||
const couchdbService = require("./couchdbService");
|
||||
|
||||
// Calculate start of month
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Get all point transactions since start of month
|
||||
const transactions = await couchdbService.find({
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
createdAt: { $gte: startOfMonth.toISOString() }
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate points by user
|
||||
const userPointsMap = {};
|
||||
transactions.forEach(transaction => {
|
||||
if (!userPointsMap[transaction.user]) {
|
||||
userPointsMap[transaction.user] = 0;
|
||||
}
|
||||
userPointsMap[transaction.user] += transaction.amount;
|
||||
});
|
||||
|
||||
// Convert to array and sort
|
||||
const userPoints = Object.entries(userPointsMap)
|
||||
.map(([userId, points]) => ({ userId, points }))
|
||||
.filter(entry => entry.points > 0)
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(offset, offset + limit);
|
||||
|
||||
// Enrich with user data
|
||||
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
|
||||
const user = await User.findById(entry.userId);
|
||||
if (!user) return null;
|
||||
|
||||
// Get user badges
|
||||
const userBadges = await UserBadge.findByUser(user._id);
|
||||
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
|
||||
const badgeData = ub.badge;
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
return badge ? {
|
||||
_id: badge._id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
rarity: badge.rarity
|
||||
} : null;
|
||||
}));
|
||||
|
||||
return {
|
||||
rank: offset + index + 1,
|
||||
userId: user._id,
|
||||
username: user.name,
|
||||
avatar: user.profilePicture || null,
|
||||
points: entry.points,
|
||||
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
|
||||
badges: badges.filter(b => b !== null)
|
||||
};
|
||||
}));
|
||||
|
||||
return leaderboard.filter(entry => entry !== null);
|
||||
} catch (error) {
|
||||
console.error("Error getting monthly leaderboard:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friends leaderboard
|
||||
* @param {string} userId - User ID
|
||||
* @param {number} limit - Number of users to return
|
||||
* @param {number} offset - Offset for pagination
|
||||
* @returns {Promise<Array>} Leaderboard data
|
||||
*/
|
||||
async function getFriendsLeaderboard(userId, limit = 100, offset = 0) {
|
||||
try {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
// For now, return empty array as friends system isn't implemented
|
||||
// In future, would get user's friends list and filter leaderboard
|
||||
const friendIds = user.friends || [];
|
||||
|
||||
if (friendIds.length === 0) {
|
||||
// Include self if no friends
|
||||
friendIds.push(userId);
|
||||
}
|
||||
|
||||
const couchdbService = require("./couchdbService");
|
||||
|
||||
// Get friends' data
|
||||
const friends = await couchdbService.find({
|
||||
selector: {
|
||||
type: "user",
|
||||
_id: { $in: friendIds }
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by points
|
||||
const sortedFriends = friends
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
.slice(offset, offset + limit);
|
||||
|
||||
// Enrich with badges
|
||||
const leaderboard = await Promise.all(sortedFriends.map(async (friend, index) => {
|
||||
const userBadges = await UserBadge.findByUser(friend._id);
|
||||
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
|
||||
const badgeData = ub.badge;
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
return badge ? {
|
||||
_id: badge._id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
rarity: badge.rarity
|
||||
} : null;
|
||||
}));
|
||||
|
||||
return {
|
||||
rank: offset + index + 1,
|
||||
userId: friend._id,
|
||||
username: friend.name,
|
||||
avatar: friend.profilePicture || null,
|
||||
points: friend.points || 0,
|
||||
streetsAdopted: friend.stats?.streetsAdopted || friend.adoptedStreets?.length || 0,
|
||||
tasksCompleted: friend.stats?.tasksCompleted || friend.completedTasks?.length || 0,
|
||||
badges: badges.filter(b => b !== null),
|
||||
isFriend: true
|
||||
};
|
||||
}));
|
||||
|
||||
return leaderboard;
|
||||
} catch (error) {
|
||||
console.error("Error getting friends leaderboard:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's leaderboard position
|
||||
* @param {string} userId - User ID
|
||||
* @param {string} timeframe - 'all', 'week', or 'month'
|
||||
* @returns {Promise<Object>} User's position data
|
||||
*/
|
||||
async function getUserLeaderboardPosition(userId, timeframe = "all") {
|
||||
try {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rank = 0;
|
||||
let totalUsers = 0;
|
||||
let userPoints = 0;
|
||||
|
||||
const couchdbService = require("./couchdbService");
|
||||
|
||||
if (timeframe === "all") {
|
||||
// Get all users with points
|
||||
const allUsers = await couchdbService.find({
|
||||
selector: {
|
||||
type: "user",
|
||||
points: { $gt: 0 }
|
||||
},
|
||||
sort: [{ points: "desc" }]
|
||||
});
|
||||
|
||||
totalUsers = allUsers.length;
|
||||
userPoints = user.points || 0;
|
||||
|
||||
// Find user's rank
|
||||
rank = allUsers.findIndex(u => u._id === userId) + 1;
|
||||
} else if (timeframe === "week" || timeframe === "month") {
|
||||
// Calculate start date
|
||||
const now = new Date();
|
||||
let startDate;
|
||||
|
||||
if (timeframe === "week") {
|
||||
const dayOfWeek = now.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
startDate = new Date(now);
|
||||
startDate.setDate(now.getDate() - daysToMonday);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
// Get all transactions for period
|
||||
const transactions = await couchdbService.find({
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
createdAt: { $gte: startDate.toISOString() }
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate points by user
|
||||
const userPointsMap = {};
|
||||
transactions.forEach(transaction => {
|
||||
if (!userPointsMap[transaction.user]) {
|
||||
userPointsMap[transaction.user] = 0;
|
||||
}
|
||||
userPointsMap[transaction.user] += transaction.amount;
|
||||
});
|
||||
|
||||
// Sort users by points
|
||||
const sortedUsers = Object.entries(userPointsMap)
|
||||
.filter(([_, points]) => points > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
totalUsers = sortedUsers.length;
|
||||
userPoints = userPointsMap[userId] || 0;
|
||||
rank = sortedUsers.findIndex(([id, _]) => id === userId) + 1;
|
||||
}
|
||||
|
||||
// Get user badges
|
||||
const userBadges = await UserBadge.findByUser(user._id);
|
||||
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
|
||||
const badgeData = ub.badge;
|
||||
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
|
||||
return badge ? {
|
||||
_id: badge._id,
|
||||
name: badge.name,
|
||||
icon: badge.icon,
|
||||
rarity: badge.rarity
|
||||
} : null;
|
||||
}));
|
||||
|
||||
return {
|
||||
rank: rank || null,
|
||||
totalUsers,
|
||||
userId: user._id,
|
||||
username: user.name,
|
||||
avatar: user.profilePicture || null,
|
||||
points: userPoints,
|
||||
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
|
||||
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
|
||||
badges: badges.filter(b => b !== null),
|
||||
percentile: totalUsers > 0 ? Math.round((1 - (rank - 1) / totalUsers) * 100) : 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting user leaderboard position:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leaderboard statistics
|
||||
* @returns {Promise<Object>} Statistics data
|
||||
*/
|
||||
async function getLeaderboardStats() {
|
||||
try {
|
||||
const couchdbService = require("./couchdbService");
|
||||
|
||||
// Get all users with points
|
||||
const allUsers = await couchdbService.find({
|
||||
selector: {
|
||||
type: "user",
|
||||
points: { $gt: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const totalUsers = allUsers.length;
|
||||
const totalPoints = allUsers.reduce((sum, user) => sum + (user.points || 0), 0);
|
||||
const avgPoints = totalUsers > 0 ? Math.round(totalPoints / totalUsers) : 0;
|
||||
const maxPoints = allUsers.length > 0 ? Math.max(...allUsers.map(u => u.points || 0)) : 0;
|
||||
const minPoints = allUsers.length > 0 ? Math.min(...allUsers.map(u => u.points || 0)) : 0;
|
||||
|
||||
// Get weekly stats
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - daysToMonday);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const weeklyTransactions = await couchdbService.find({
|
||||
selector: {
|
||||
type: "point_transaction",
|
||||
createdAt: { $gte: startOfWeek.toISOString() }
|
||||
}
|
||||
});
|
||||
|
||||
const weeklyPoints = weeklyTransactions.reduce((sum, t) => sum + (t.amount || 0), 0);
|
||||
const activeUsersThisWeek = new Set(weeklyTransactions.map(t => t.user)).size;
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalPoints,
|
||||
avgPoints,
|
||||
maxPoints,
|
||||
minPoints,
|
||||
weeklyStats: {
|
||||
totalPoints: weeklyPoints,
|
||||
activeUsers: activeUsersThisWeek,
|
||||
transactions: weeklyTransactions.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting leaderboard statistics:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
awardPoints,
|
||||
getUserPoints,
|
||||
@@ -308,7 +827,15 @@ module.exports = {
|
||||
checkAndAwardBadges,
|
||||
awardBadge,
|
||||
getUserBadges,
|
||||
getUserBadgeProgress,
|
||||
getUserStats,
|
||||
redeemPoints,
|
||||
getLeaderboard,
|
||||
getGlobalLeaderboard,
|
||||
getWeeklyLeaderboard,
|
||||
getMonthlyLeaderboard,
|
||||
getFriendsLeaderboard,
|
||||
getUserLeaderboardPosition,
|
||||
getLeaderboardStats,
|
||||
POINT_VALUES,
|
||||
};
|
||||
Generated
+392
-3
@@ -14,6 +14,7 @@
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.8.3",
|
||||
"caniuse-lite": "^1.0.30001753",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.3.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -3270,6 +3272,42 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
|
||||
"integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
@@ -3400,6 +3438,18 @@
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-6.0.0.tgz",
|
||||
@@ -3855,6 +3905,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.56.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz",
|
||||
@@ -4116,6 +4229,12 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
|
||||
@@ -5715,9 +5834,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001704",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz",
|
||||
"integrity": "sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew==",
|
||||
"version": "1.0.30001753",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz",
|
||||
"integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6621,6 +6740,127 @@
|
||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -6715,6 +6955,12 @@
|
||||
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
|
||||
@@ -7416,6 +7662,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.41.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz",
|
||||
"integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -9602,6 +9858,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
||||
@@ -14442,6 +14707,29 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||
@@ -14604,6 +14892,49 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
|
||||
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/recursive-readdir": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
|
||||
@@ -14629,6 +14960,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -14803,6 +15149,12 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -16730,6 +17082,12 @@
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.17",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
|
||||
@@ -17203,6 +17561,15 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -17277,6 +17644,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-hr-time": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.3.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
||||
@@ -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