diff --git a/firestore.rules b/firestore.rules
index 3aca3c7..3ce557d 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -108,6 +108,8 @@ service cloud.firestore {
// Locks referralPoints to the actual totalEarned value in the referrals index.
allow update: if isAuthenticated()
&& request.auth.uid != uid
+ // 🛡️ NEW GUARD (Issue #432): Only users actively onboarding can trigger a referral point grant
+ && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete"
// Prevent modifying any root-level fields other than points
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['points'])
// FIX: Sync referralPoints directly with the referrals index document's totalEarned counter
@@ -120,7 +122,6 @@ service cloud.firestore {
&& request.resource.data.points.auditorPoints == resource.data.points.auditorPoints
&& exists(/databases/$(database)/documents/referrals/$(uid))
&& request.auth.uid in get(/databases/$(database)/documents/referrals/$(uid)).data.usedBy;
- }
match /referrals/{uid} {
allow read: if isAuthenticated();
@@ -131,6 +132,8 @@ service cloud.firestore {
// Allow either the owner to write, OR a referred user to atomically append their UID
allow update: if isOwner(uid) || (
isAuthenticated()
+ // 🛡️ NEW GUARD (Issue #432): Prevent existing users from arbitrarily claiming codes
+ && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.onboardingStatus == "incomplete"
&& !(request.auth.uid in resource.data.usedBy)
&& request.resource.data.usedBy.size() == resource.data.usedBy.size() + 1
&& request.resource.data.usedBy[resource.data.usedBy.size()] == request.auth.uid
diff --git a/src/components/ui/ErrorBoundary.jsx b/src/components/ui/ErrorBoundary.jsx
index e286456..0f9a2f9 100644
--- a/src/components/ui/ErrorBoundary.jsx
+++ b/src/components/ui/ErrorBoundary.jsx
@@ -1,9 +1,10 @@
import React, { Component } from "react";
+import { motion } from "framer-motion";
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
- this.state = { hasError: false, error: null };
+ this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
@@ -12,13 +13,30 @@ export class ErrorBoundary extends Component {
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
+ this.setState({ errorInfo });
}
+ // ADDED: Graceful recovery method (No full page reload)
+ handleRetry = () => {
+ this.setState({ hasError: false, error: null, errorInfo: null });
+ };
+
render() {
if (this.state.hasError) {
+ // ADDED: Dynamic context-specific message
+ const customMessage = this.props.fallbackMessage || "The application encountered an unexpected error. Don't worry, your data is safe.";
+
return (
-
-
+ // Changed min-h-screen to min-h-[60vh] so it fits perfectly inside dashboard layouts too
+
+
+ {/* ADDED: Framer motion wrapper for entry animation */}
+
{/* Premium CSS-Animated SVG warning graphic */}
{/* Glowing backdrop rings */}
@@ -33,19 +51,31 @@ export class ErrorBoundary extends Component {
+
Something went wrong.
+
- The application encountered an unexpected error. Don't worry, your data is safe.
+ {customMessage}
+
+ {/* ADDED: Hidden technical details for developers (only shows in localhost/dev mode) */}
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+
+ {this.state.error.toString()}
+
+
+ )}
+
-
+
);
}
@@ -54,4 +84,4 @@ export class ErrorBoundary extends Component {
}
}
-export default ErrorBoundary;
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/src/context/FirestoreCacheContext.jsx b/src/context/FirestoreCacheContext.jsx
new file mode 100644
index 0000000..48d0286
--- /dev/null
+++ b/src/context/FirestoreCacheContext.jsx
@@ -0,0 +1,76 @@
+import React, { createContext, useContext, useRef, useState, useEffect } from 'react';
+
+const CacheContext = createContext();
+
+export const FirestoreCacheProvider = ({ children }) => {
+ // Map store to hold cached data and timestamps
+ const cache = useRef(new Map());
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useFirestoreCache = (cacheKey, fetcherFn, ttl = 60000) => {
+ const cache = useContext(CacheContext);
+ if (!cache) {
+ throw new Error("useFirestoreCache must be used within a FirestoreCacheProvider");
+ }
+
+ // Initialize with cached data if it exists
+ const [data, setData] = useState(() => {
+ const cached = cache.current.get(cacheKey);
+ return cached ? cached.data : null;
+ });
+
+ // Only show loading spinner if we have absolutely no data
+ const [loading, setLoading] = useState(!data);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const executeFetch = async () => {
+ const cached = cache.current.get(cacheKey);
+ const now = Date.now();
+
+ // If data exists and is within TTL, skip fetching entirely
+ if (cached && (now - cached.ts < ttl)) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const freshData = await fetcherFn();
+ if (!isMounted) return;
+
+ // Deep Comparison Check (prevents unnecessary re-renders)
+ const isDataChanged = !cached || JSON.stringify(cached.data) !== JSON.stringify(freshData);
+
+ if (isDataChanged) {
+ setData(freshData);
+ cache.current.set(cacheKey, { data: freshData, ts: Date.now() });
+ }
+ } catch (err) {
+ if (isMounted) setError(err);
+ } finally {
+ if (isMounted) setLoading(false);
+ }
+ };
+
+ executeFetch();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [cacheKey]); // Important: fetcherFn is omitted to prevent infinite loops
+
+ // Function to manually invalidate cache (e.g., after a mutation)
+ const invalidateCache = () => {
+ cache.current.delete(cacheKey);
+ };
+
+ return { data, loading, error, invalidateCache };
+};
\ No newline at end of file
diff --git a/src/pages/GitRank.jsx b/src/pages/GitRank.jsx
index 92b984f..e4c4630 100644
--- a/src/pages/GitRank.jsx
+++ b/src/pages/GitRank.jsx
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useMemo } from "react";
import { Search, Filter, Star, Trophy, RefreshCw, GitCommit, Calendar, BookOpen, AlertCircle, CheckCircle2, Users, Medal } from "lucide-react";
-import { collection, query, doc, where, orderBy, limit, startAfter, onSnapshot, getDocs, runTransaction, serverTimestamp } from "firebase/firestore";
+import { collection, query, doc, where, orderBy, limit, startAfter, getDocs, runTransaction, serverTimestamp } from "firebase/firestore";
import { useSearchParams } from "react-router-dom";
import { TableVirtuoso } from "react-virtuoso";
import { db } from "../lib/firebase";
import { useAuth } from "../context/AuthContext";
+import { useFirestoreCache } from "../context/FirestoreCacheContext";
import Card from "../components/ui/Card";
import SectionHeader from "../components/ui/SectionHeader";
import GradientButton from "../components/ui/GradientButton";
@@ -13,16 +14,10 @@ import axios from "axios";
export const GitRank = () => {
const { user, userData, fetchGitHubStats, login } = useAuth();
- // ============================================================
- // ISSUE #194: URL Parameter Sync for State Persistence
- // ISSUE #362: Server-Side College Filter Integration
- // ============================================================
const [searchParams, setSearchParams] = useSearchParams();
const searchTerm = searchParams.get("search") || "";
const selectedLanguage = searchParams.get("lang") || "All";
- const selectedCollege = searchParams.get("college") || "All"; // NEW: Server-Side College State
-
- // Active Tab for Referral Leaderboard (Issue #310) - Now synced with URL
+ const selectedCollege = searchParams.get("college") || "All";
const activeTab = searchParams.get("tab") || "gitrank";
const handleSearchChange = (e) => {
@@ -59,9 +54,8 @@ export const GitRank = () => {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
- // Real-time leaderboard state
+ // Local Leaderboard state
const [usersList, setUsersList] = useState([]);
- const [loadingUsers, setLoadingUsers] = useState(true);
// Syncing state
const [isSyncing, setIsSyncing] = useState(false);
@@ -77,12 +71,10 @@ export const GitRank = () => {
const languages = ["All", "TypeScript", "Rust", "Go", "Python", "Kotlin", "Ruby", "JavaScript"];
- // 1. Real-time Leaderboard Listener (Server-Side Filtered)
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setLoadingUsers(true);
+ // In-Memory Navigation Cache Hook Integration
+ const cacheKey = `gitrank_${activeTab}_${selectedLanguage}_${selectedCollege}`;
- // Build the query dynamically based on Active Tab and Tie-Breakers
+ const fetchLeaderboard = async () => {
const constraints = [
where("onboardingStatus", "==", "complete"),
];
@@ -96,12 +88,10 @@ export const GitRank = () => {
constraints.push(orderBy("githubUsername", "asc"));
- // DB level language filter
if (selectedLanguage !== "All") {
constraints.push(where("githubStats.primaryLanguage", "==", selectedLanguage));
}
- // DB level college filter (Resolves Issue #362)
if (selectedCollege !== "All" && selectedCollege.trim() !== "") {
constraints.push(where("college", "==", selectedCollege));
}
@@ -109,33 +99,37 @@ export const GitRank = () => {
constraints.push(limit(50));
const q = query(collection(db, "users"), ...constraints);
+ const snapshot = await getDocs(q); // Switched from onSnapshot to getDocs to save reads
- const unsubscribe = onSnapshot(
- q,
- (snapshot) => {
- const users = [];
- snapshot.forEach((doc) => {
- users.push(doc.data());
- });
+ const users = [];
+ snapshot.forEach((doc) => {
+ users.push(doc.data());
+ });
- const ranked = users.map((u, i) => ({
- ...u,
- rank: i + 1
- }));
-
- setUsersList(ranked);
- setLastVisible(snapshot.docs[snapshot.docs.length - 1]);
- setHasMore(snapshot.docs.length === 50);
- setLoadingUsers(false);
- },
- (error) => {
- console.error("Leaderboard subscription error:", error);
- setLoadingUsers(false);
- }
- );
+ const ranked = users.map((u, i) => ({
+ ...u,
+ rank: i + 1
+ }));
+
+ // Return the bundle so cache hook holds both data and pagination cursors
+ return {
+ users: ranked,
+ lastVisible: snapshot.docs[snapshot.docs.length - 1] || null,
+ hasMore: snapshot.docs.length === 50
+ };
+ };
+
+ // TTL set to 60000ms (1 minute). Modifying filters changes the key instantly.
+ const { data: cachedData, loading: loadingUsers, invalidateCache } = useFirestoreCache(cacheKey, fetchLeaderboard, 60000);
- return () => unsubscribe();
- }, [selectedLanguage, activeTab, selectedCollege]);
+ // Sync the cached data payload into local component state
+ useEffect(() => {
+ if (cachedData) {
+ setUsersList(cachedData.users);
+ setLastVisible(cachedData.lastVisible);
+ setHasMore(cachedData.hasMore);
+ }
+ }, [cachedData]);
// Pagination Function (Fetch next 50)
const loadMoreUsers = async () => {
@@ -200,7 +194,6 @@ export const GitRank = () => {
// 2. Fetch GitHub Events/Repos for Charts (Authenticated Only)
useEffect(() => {
if (!userData?.githubUsername) {
- // eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingCharts(false);
return;
}
@@ -343,6 +336,9 @@ export const GitRank = () => {
});
});
+ // Clear the cache manually because our own points just updated!
+ invalidateCache();
+
setSyncSuccess("GitHub statistics updated in real time!");
setTimeout(() => setSyncSuccess(""), 4000);
} catch (err) {
@@ -390,7 +386,6 @@ export const GitRank = () => {
return `${mins}m ${secs}s`;
};
-// Filter leaderboard lists (Only Search is client side now)
const filteredData = useMemo(() => {
return usersList.filter((u) => {
const name = u.name || "";
@@ -433,7 +428,7 @@ export const GitRank = () => {
return weeks;
}, [events]);
-// Chart Parsing 2: Languages Frequency
+ // Chart Parsing 2: Languages Frequency
const languageChartData = useMemo(() => {
if (!repos.length) return [];
diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx
index a6fb109..9dc0507 100644
--- a/src/routes/AppRoutes.jsx
+++ b/src/routes/AppRoutes.jsx
@@ -1,35 +1,40 @@
-import React from "react";
+import React, { Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import PublicLayout from "../layouts/PublicLayout";
import DashboardLayout from "../layouts/DashboardLayout";
-import Home from "../pages/Home";
-import Dashboard from "../pages/Dashboard";
-import GitRank from "../pages/GitRank";
-import RankHer from "../pages/RankHer";
-import CodingVerse from "../pages/CodingVerse";
-import CodingOwl from "../pages/CodingOwl";
-import Matchmaker from "../pages/Matchmaker";
-import Profile from "../pages/Profile";
-import Friends from "../pages/Friends";
-import Login from "../pages/Login";
-import Onboarding from "../pages/Onboarding";
-import NotFound from "../pages/NotFound";
-import Achievements from "../pages/Achievements";
-import About from "../pages/About";
-import Terms from "../pages/Terms";
-import Privacy from "../pages/Privacy";
import ComingSoonCard from "../components/ui/ComingSoonCard";
import GlobalModals from "../components/ui/GlobalModals";
import { Settings as SettingsIcon } from "lucide-react";
-import Auditor from "../pages/Auditor";
+import ErrorBoundary from "../components/ui/ErrorBoundary";
+import { FirestoreCacheProvider } from "../context/FirestoreCacheContext";
+
+// Lazy Loaded Pages to reduce initial JS bundle
+const Home = React.lazy(() => import("../pages/Home"));
+const Dashboard = React.lazy(() => import("../pages/Dashboard"));
+const GitRank = React.lazy(() => import("../pages/GitRank"));
+const RankHer = React.lazy(() => import("../pages/RankHer"));
+const CodingVerse = React.lazy(() => import("../pages/CodingVerse"));
+const CodingOwl = React.lazy(() => import("../pages/CodingOwl"));
+const Matchmaker = React.lazy(() => import("../pages/Matchmaker"));
+const Profile = React.lazy(() => import("../pages/Profile"));
+const Friends = React.lazy(() => import("../pages/Friends"));
+const Login = React.lazy(() => import("../pages/Login"));
+const Onboarding = React.lazy(() => import("../pages/Onboarding"));
+const NotFound = React.lazy(() => import("../pages/NotFound"));
+const Achievements = React.lazy(() => import("../pages/Achievements"));
+const About = React.lazy(() => import("../pages/About"));
+const Terms = React.lazy(() => import("../pages/Terms"));
+const Privacy = React.lazy(() => import("../pages/Privacy"));
+const Auditor = React.lazy(() => import("../pages/Auditor"));
+const CardBuilder = React.lazy(() => import("../pages/CardBuilder"));
// Inline loading indicator
const LoadingScreen = ({ message }) => (
-
{message || "Syncing Session..."}
+
{message || "Loading..."}
);
@@ -65,8 +70,6 @@ const OnboardingRoute = ({ children }) => {
return ;
}
- // Strict guard: if the user's data explicitly says they are complete, OR if isOnboarding is false, redirect.
- // We only allow access if they are explicitly incomplete.
if (userData?.onboardingStatus === "complete" || !isOnboarding || (userData && userData.onboardingStatus !== "incomplete")) {
return ;
}
@@ -79,7 +82,7 @@ const GuestRoute = ({ children }) => {
const { user, loading, isOnboarding } = useAuth();
if (loading) {
- return null; // Don't redirect prematurely while state is resolving
+ return null;
}
if (user) {
@@ -92,9 +95,6 @@ const GuestRoute = ({ children }) => {
return children;
};
-import CardBuilder from "../pages/CardBuilder";
-
-// An inline settings page to keep route integrated
const SettingsPage = () => (
(
);
+// Helper wrapper to easily wrap lazy components with ErrorBoundary
+const withErrorBoundary = (Component, componentName, fallbackMessage) => (
+
+
+
+);
+
export const AppRoutes = () => {
return (
<>
-
- {/* Public Site Layout & Pages */}
- }>
- } />
- } />
- } />
- } />
- } />
-
-
- {/* Standalone About Us page */}
- } />
-
- {/* Standalone Legal pages */}
- } />
- } />
-
- {/* Public Login page (standalone) - guarded from logged in users */}
- } />
-
- {/* Onboarding page (standalone) - guarded so only incomplete profiles see it */}
- } />
-
- {/* Layout dashboard sub-pages - locked to authenticated & fully onboarded users */}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
- {/* 404 Catch All */}
- } />
-
+
+ }>
+
+ {/* Public Site Layout & Pages */}
+ }>
+
+
+
+
+
+
+
+ {/* Standalone About Us page */}
+
+
+ {/* Standalone Legal pages */}
+
+
+
+ {/* Public Login page */}
+ {withErrorBoundary(Login, "Login", "Failed to initialize the login portal.")}} />
+
+ {/* Onboarding page */}
+ {withErrorBoundary(Onboarding, "Onboarding", "Failed to load the onboarding flow.")}} />
+
+ {/* Layout dashboard sub-pages */}
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+ {/* 404 Catch All */}
+ } />
+
+
+
>
);
};
-export default AppRoutes;
+export default AppRoutes;
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
index 1c09993..e2c14a7 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -36,6 +36,11 @@ export default defineConfig({
if (id.includes('framer-motion')) {
return 'vendor-motion';
}
+
+ // Swiper (Carousel)
+ if (id.includes('swiper')) {
+ return 'vendor-swiper';
+ }
// All other node_modules
return 'vendor';