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';