diff --git a/src/app/pathway/page.tsx b/src/app/pathway/page.tsx index 729ef926..9a097fc1 100644 --- a/src/app/pathway/page.tsx +++ b/src/app/pathway/page.tsx @@ -16,6 +16,14 @@ import { Gift, Calendar, ChartNoAxesCombined, + CheckCircle2, + GitBranch, + GitPullRequest, + Heart, + MessageSquare, + Sparkles, + Upload, + UserCheck, } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; @@ -27,6 +35,117 @@ interface LeaderboardEntry { points?: number; } +const pointEarningActivities = [ + { + label: 'Daily Login', + value: `+${POINTS.DAILY_LOGIN} (+Streak)`, + Icon: Flame, + iconClassName: 'text-orange-500', + }, + { + label: '7-Day Streak', + value: `+${POINTS.WEEKLY_STREAK_BONUS}`, + Icon: Flame, + iconClassName: 'text-red-500', + }, + { + label: 'Follow Community', + value: `+${POINTS.FOLLOW_COMMUNITY}`, + Icon: Users, + iconClassName: 'text-blue-500', + }, + { + label: 'Gain Follower', + value: `+${POINTS.FOLLOWER_GAINED}`, + Icon: UserCheck, + iconClassName: 'text-green-500', + }, + { + label: 'Earn Badge', + value: 'Dynamic', + Icon: Award, + iconClassName: 'text-purple-500', + }, + { + label: 'Project Star', + value: `+${POINTS.PROJECT_STAR}`, + Icon: Star, + iconClassName: 'text-yellow-500', + }, + { + label: 'Event Participation', + value: `+${POINTS.EVENT_PARTICIPATION}`, + Icon: Calendar, + iconClassName: 'text-pink-500', + }, + { + label: 'Hackathon Win', + value: `+${POINTS.HACKATHON_WIN}`, + Icon: Trophy, + iconClassName: 'text-yellow-600', + }, + { + label: 'Profile Completion', + value: `+${POINTS.PROFILE_COMPLETION}`, + Icon: CheckCircle2, + iconClassName: 'text-emerald-500', + }, + { + label: 'First Project Upload', + value: `+${POINTS.FIRST_PROJECT_UPLOAD}`, + Icon: Upload, + iconClassName: 'text-cyan-500', + }, + { + label: 'Repository Contribution', + value: `+${POINTS.REPOSITORY_CONTRIBUTION}`, + Icon: GitBranch, + iconClassName: 'text-sky-500', + }, + { + label: 'Pull Request Merged', + value: `+${POINTS.PULL_REQUEST_MERGED}`, + Icon: GitPullRequest, + iconClassName: 'text-violet-500', + }, + { + label: 'Issue Resolution', + value: `+${POINTS.ISSUE_RESOLUTION}`, + Icon: CheckCircle2, + iconClassName: 'text-lime-500', + }, + { + label: 'Community Post Creation', + value: `+${POINTS.COMMUNITY_POST_CREATION}`, + Icon: MessageSquare, + iconClassName: 'text-teal-500', + }, + { + label: 'Helpful Comment Received', + value: `+${POINTS.HELPFUL_COMMENT_RECEIVED}`, + Icon: Heart, + iconClassName: 'text-rose-500', + }, + { + label: 'Consecutive Weekly Activity', + value: `+${POINTS.CONSECUTIVE_WEEKLY_ACTIVITY}`, + Icon: Flame, + iconClassName: 'text-amber-500', + }, + { + label: 'Open Source Contribution', + value: `+${POINTS.OPEN_SOURCE_CONTRIBUTION}`, + Icon: GitPullRequest, + iconClassName: 'text-indigo-500', + }, + { + label: 'Mentor Recognition', + value: `+${POINTS.MENTOR_RECOGNITION}`, + Icon: Sparkles, + iconClassName: 'text-fuchsia-500', + }, +]; + export default function PathwayPage() { const { user } = useAuth(); const [leaderboard, setLeaderboard] = useState([]); @@ -416,72 +535,26 @@ export default function PathwayPage() { How to Earn Points -
-
- - Daily Login - - - +{POINTS.DAILY_LOGIN} (+Streak) - -
-
- - 7-Day Streak - - - +{POINTS.WEEKLY_STREAK_BONUS} - -
-
- - Follow Community - - - +{POINTS.FOLLOW_COMMUNITY} - -
-
- - Earn Badge - - - +{POINTS.BADGE_EARNED} - -
-
- - Gain Follower - - - +{POINTS.FOLLOWER_GAINED} - -
-
- - Project Star - - - +{POINTS.PROJECT_STAR} - -
-
- - Event - Participation - - - +{POINTS.EVENT_PARTICIPATION} - -
-
- - Hackathon Win - - - +{POINTS.HACKATHON_WIN} - -
+
+ {pointEarningActivities.map( + ({ label, value, Icon, iconClassName }) => ( +
+ + + {label} + + + {value} + +
+ ) + )}
diff --git a/src/components/projects/ProjectUploadModal.tsx b/src/components/projects/ProjectUploadModal.tsx index e6c2caea..bdcac6f1 100644 --- a/src/components/projects/ProjectUploadModal.tsx +++ b/src/components/projects/ProjectUploadModal.tsx @@ -19,6 +19,8 @@ import { doc, serverTimestamp, increment, + getDoc, + arrayUnion, } from 'firebase/firestore'; import { db } from '@/lib/firebase'; import { POINTS } from '@/lib/points'; @@ -82,6 +84,16 @@ const POPULAR_SKILLS = [ 'Data Science', ]; +interface ProjectFormData { + id?: string; + title?: string; + description?: string; + websiteUrl?: string; + skills?: string[]; + screenshots?: string[]; + videoUrl?: string; +} + interface ProjectUploadModalProps { isOpen: boolean; onClose: () => void; @@ -89,14 +101,13 @@ interface ProjectUploadModalProps { userEmail?: string | null; userName: string; onSuccess: () => void; - initialData?: any; // Project data for editing + initialData?: ProjectFormData; } export default function ProjectUploadModal({ isOpen, onClose, userId, - userEmail, userName, onSuccess, initialData, @@ -220,23 +231,35 @@ export default function ProjectUploadModal({ const memberRef = doc(db, 'members', userId); const leaderboardRef = doc(db, 'leaderboard', userId); const today = new Date().toISOString().split('T')[0]; + const memberSnap = await getDoc(memberRef); + const achievements = memberSnap.exists() + ? memberSnap.data().achievements || [] + : []; + const shouldAwardFirstProject = !achievements.includes( + 'first_project_upload' + ); batch.set(rootRef, newProjectData); batch.set(subRef, newProjectData); // XP award is part of the same atomic batch — it only lands if both // project writes succeed. - batch.update(memberRef, { points: increment(POINTS.CREATE_PROJECT) }); - batch.set( - leaderboardRef, - { - uid: userId, - name: userName, - points: increment(POINTS.CREATE_PROJECT), - role: 'member', - lastActive: today, - }, - { merge: true } - ); + if (shouldAwardFirstProject) { + batch.update(memberRef, { + achievements: arrayUnion('first_project_upload'), + points: increment(POINTS.FIRST_PROJECT_UPLOAD), + }); + batch.set( + leaderboardRef, + { + uid: userId, + name: userName, + points: increment(POINTS.FIRST_PROJECT_UPLOAD), + role: 'member', + lastActive: today, + }, + { merge: true } + ); + } } await batch.commit(); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index c9ace7f3..02efa8cb 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -12,11 +12,11 @@ import { onAuthStateChanged, signInWithEmailAndPassword, signOut, - User as FirebaseUser, setPersistence, browserLocalPersistence, } from 'firebase/auth'; import { doc, getDoc, setDoc } from 'firebase/firestore'; +import type { FieldValue } from 'firebase/firestore'; import { leaderboardSyncErrorEmitter } from '@/lib/leaderboard-sync-error'; interface User { @@ -63,7 +63,7 @@ interface User { followers?: number; following?: number; contributions?: number; - lastFetched?: any; + lastFetched?: unknown; recentActivity?: { id: string; type: string; @@ -88,7 +88,7 @@ interface User { badges?: string[]; sessionId?: string; docId?: string; // Actual Firestore Document ID (Email or UID) - createdAt?: any; + createdAt?: unknown; } interface AuthContextType { @@ -174,11 +174,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // This ensures their session is tracked and they are validated securely via Firestore `admins` collection. let role: 'admin' | 'member' = 'member'; - let userData: any = { + let userData: User & Record = { uid: firebaseUser.uid, email: firebaseUser.email, name: firebaseUser.displayName, photoURL: firebaseUser.photoURL, + role, showMobile: false, showLocation: true, showEmail: false, @@ -340,10 +341,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { calculateStreak, getISTDateString } = await import('@/lib/streakUtils'); const today = getISTDateString(new Date()); - let loginDates = userData.loginDates || []; + const loginDates = userData.loginDates || []; let shouldUpdate = false; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updateData: Record = {}; + const updateData: Record = {}; let pointsDelta = 0; // 1. Check if new day login @@ -358,9 +358,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { currentStreak } = calculateStreak(loginDates); if (shouldUpdate || userData.streak !== currentStreak) { - // Award 1 XP if streak increased (Daily Login) + // Award daily login XP plus a capped streak multiplier. if (currentStreak > (userData.streak || 0)) { - pointsDelta += POINTS.DAILY_LOGIN; + const streakMultiplier = Math.min( + Math.max(currentStreak - 1, 0), + 6 + ); + pointsDelta += + POINTS.DAILY_LOGIN + + streakMultiplier * POINTS.STREAK_BONUS_PER_DAY; // 7-Day Streak Bonus if (currentStreak % 7 === 0 && currentStreak > 0) { @@ -376,7 +382,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (shouldUpdate) { const { increment } = await import('firebase/firestore'); - const firestoreUpdate: any = { ...updateData }; + const firestoreUpdate: Record = { ...updateData }; if (pointsDelta > 0) { firestoreUpdate.points = increment(pointsDelta); } @@ -443,7 +449,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { unsubscribeSnapshot.current(); } }; - }, []); + }, [firebaseReady]); const verifyAdmin = () => { setIsAdminVerified(true); @@ -508,14 +514,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Remove undefined fields const cleanData = Object.fromEntries( - Object.entries(data).filter(([_, v]) => v !== undefined) - ); + Object.entries(data).filter(([, value]) => value !== undefined) + ) as Partial; - if ((cleanData as any).points !== undefined) { + if (cleanData.points !== undefined) { console.warn( '[updateUserProfile] Ignoring `points` field. Use awardPoints(pointsDelta) instead.' ); - delete (cleanData as any).points; + delete cleanData.points; } await setDoc(doc(db, collectionName, docId), cleanData, { merge: true }); @@ -588,8 +594,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const followUser = async ( targetUserId: string, - targetRole: string = 'member', - targetEmail?: string + targetRole: string = 'member' ) => { if (!firebaseReady || !user) return; if (user.uid === targetUserId) return; // Cannot follow self @@ -620,7 +625,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const targetUserRef = doc(db, targetCollection, targetDocId); - const updateData: any = { + const updateData: { followers: FieldValue; points?: FieldValue } = { followers: arrayUnion(user.uid), }; @@ -666,8 +671,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const unfollowUser = async ( targetUserId: string, - targetRole: string = 'member', - targetEmail?: string + targetRole: string = 'member' ) => { if (!firebaseReady || !user) return; if (user.email === SUPER_ADMIN_EMAIL) return; // Super Admin Guard @@ -696,7 +700,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const targetUserRef = doc(db, targetCollection, targetDocId); - const updateData: any = { + const updateData: { followers: FieldValue; points?: FieldValue } = { followers: arrayRemove(user.uid), }; diff --git a/src/context/__tests__/AuthContext.test.tsx b/src/context/__tests__/AuthContext.test.tsx index 39514f05..fd2e5afc 100644 --- a/src/context/__tests__/AuthContext.test.tsx +++ b/src/context/__tests__/AuthContext.test.tsx @@ -2,6 +2,7 @@ import '@testing-library/jest-dom'; import React from 'react'; import { render, screen, act } from '@testing-library/react'; import { AuthProvider, useAuth } from '../AuthContext'; +import * as firebaseLib from '@/lib/firebase'; const mockSignIn = jest.fn(); const mockSignOut = jest.fn(); @@ -9,8 +10,8 @@ const mockSignOut = jest.fn(); jest.mock('firebase/auth', () => ({ getAuth: () => ({}), onAuthStateChanged: jest.fn(() => jest.fn()), - signInWithEmailAndPassword: (...args: any[]) => mockSignIn(...args), - signOut: (...args: any[]) => mockSignOut(...args), + signInWithEmailAndPassword: (...args: unknown[]) => mockSignIn(...args), + signOut: (...args: unknown[]) => mockSignOut(...args), setPersistence: jest.fn(() => Promise.resolve()), browserLocalPersistence: {}, })); @@ -25,18 +26,20 @@ jest.mock('firebase/firestore', () => ({ doc: jest.fn(() => ({})), getDoc: jest.fn(() => Promise.resolve({ exists: () => false })), setDoc: jest.fn(() => Promise.resolve()), - onSnapshot: jest.fn((_: any, cb: any) => { - cb({ exists: () => false }); - return jest.fn(); - }), + onSnapshot: jest.fn( + (_ref: unknown, cb: (snapshot: { exists: () => boolean }) => void) => { + cb({ exists: () => false }); + return jest.fn(); + } + ), writeBatch: jest.fn(() => ({ commit: jest.fn(), set: jest.fn(), update: jest.fn(), })), increment: jest.fn((n: number) => n), - arrayUnion: jest.fn((...args: any[]) => args), - arrayRemove: jest.fn((...args: any[]) => args), + arrayUnion: jest.fn((...args: unknown[]) => args), + arrayRemove: jest.fn((...args: unknown[]) => args), getFirestore: jest.fn(() => ({})), })); @@ -50,7 +53,11 @@ jest.mock('@/lib/streakUtils', () => ({ })); jest.mock('@/lib/points', () => ({ - POINTS: { DAILY_LOGIN: 1, WEEKLY_STREAK_BONUS: 20 }, + POINTS: { + DAILY_LOGIN: 1, + WEEKLY_STREAK_BONUS: 20, + STREAK_BONUS_PER_DAY: 1, + }, })); function TestHarness() { @@ -59,8 +66,8 @@ function TestHarness() { const handleLogin = async () => { try { await login('test@test.com', 'pass123'); - } catch (e: any) { - setError(e.message); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); } }; return ( @@ -150,8 +157,7 @@ describe('AuthContext', () => { }); it('throws readable error when Firebase is unavailable', async () => { - const firebaseLib = require('@/lib/firebase'); - firebaseLib.firebaseAvailable = false; + jest.replaceProperty(firebaseLib, 'firebaseAvailable', false); let error: string | null = null; function TestPage() { @@ -161,8 +167,8 @@ describe('AuthContext', () => { onClick={async () => { try { await login('a@b.com', 'p'); - } catch (e: any) { - error = e.message; + } catch (e: unknown) { + error = e instanceof Error ? e.message : String(e); } }} > @@ -183,7 +189,7 @@ describe('AuthContext', () => { 'Firebase is not configured. Login is unavailable in local UI-only mode.' ); - firebaseLib.firebaseAvailable = true; + jest.replaceProperty(firebaseLib, 'firebaseAvailable', true); }); }); }); diff --git a/src/lib/__tests__/point-calculation.test.ts b/src/lib/__tests__/point-calculation.test.ts new file mode 100644 index 00000000..55869acd --- /dev/null +++ b/src/lib/__tests__/point-calculation.test.ts @@ -0,0 +1,55 @@ +import { + calculateUserPointsAndBadges, + determineBadges, + getBadgeXp, +} from '../point-calculation'; +import { BADGE_RARITY_POINTS, POINTS } from '../points'; + +describe('point calculation', () => { + it('awards badge XP based on badge rarity', () => { + expect(getBadgeXp('builder-10')).toBe(BADGE_RARITY_POINTS.legendary); + expect(getBadgeXp('builder-5')).toBe(BADGE_RARITY_POINTS.epic); + expect(getBadgeXp('social-github')).toBe(BADGE_RARITY_POINTS.rare); + }); + + it('falls back to standard badge XP for unknown badges', () => { + expect(getBadgeXp('custom-community-badge')).toBe(POINTS.BADGE_EARNED); + }); + + it('derives earned badges from profile and project activity', () => { + const badges = determineBadges( + { + uid: 'member-1', + name: 'DevPath Member', + bio: 'Building projects and helping the community.', + photoURL: '/avatar.png', + role: 'Developer', + github: 'https://github.com/member', + linkedin: 'https://linkedin.com/in/member', + instagram: 'https://instagram.com/member', + streak: 7, + }, + [{}, {}, {}] + ); + + expect(badges).toEqual( + expect.arrayContaining([ + 'profile-perfect', + 'connector-social', + 'builder-1', + 'builder-3', + 'streak-7', + ]) + ); + }); + + it('keeps the legacy shim from overwriting transactional points', () => { + const result = calculateUserPointsAndBadges( + { uid: 'member-1', streak: 7 }, + [] + ); + + expect(result.achievements).toContain('streak-7'); + expect(result.points).toBeNull(); + }); +}); diff --git a/src/lib/point-calculation.ts b/src/lib/point-calculation.ts index 4491244b..88108c2f 100644 --- a/src/lib/point-calculation.ts +++ b/src/lib/point-calculation.ts @@ -1,4 +1,5 @@ -import { POINTS } from './points'; +import { BADGE_RARITY_POINTS, POINTS } from './points'; +import type { BadgeRarity } from './points'; export const SOCIAL_BADGES = [ 'social-github', @@ -6,6 +7,23 @@ export const SOCIAL_BADGES = [ 'social-instagram', ]; +export const BADGE_RARITY_BY_ID: Record = { + 'profile-perfect': 'uncommon', + storyteller: 'common', + 'face-of-community': 'common', + 'local-hero': 'common', + 'connector-social': 'rare', + 'social-github': 'rare', + 'social-linkedin': 'rare', + 'social-instagram': 'rare', + 'builder-1': 'uncommon', + 'builder-3': 'rare', + 'builder-5': 'epic', + 'builder-10': 'legendary', + 'streak-7': 'rare', + 'early-adopter': 'legendary', +}; + export interface UserData { uid: string; name?: string; @@ -31,6 +49,12 @@ export interface ProjectData { * Social badges (GitHub, LinkedIn, Instagram) award more XP than standard badges. */ export function getBadgeXp(badgeId: string): number { + const rarity = BADGE_RARITY_BY_ID[badgeId]; + + if (rarity) { + return BADGE_RARITY_POINTS[rarity]; + } + return SOCIAL_BADGES.includes(badgeId) ? POINTS.SOCIAL_BADGE_EARNED : POINTS.BADGE_EARNED; diff --git a/src/lib/points.ts b/src/lib/points.ts index 2a382bb3..6a2d56b1 100644 --- a/src/lib/points.ts +++ b/src/lib/points.ts @@ -1,18 +1,38 @@ export const POINTS = { DAILY_LOGIN: 1, // Base login is 1 WEEKLY_STREAK_BONUS: 20, - FOLLOW_COMMUNITY: 500, + FOLLOW_COMMUNITY: 50, BADGE_EARNED: 20, // Standard badge SOCIAL_BADGE_EARNED: 50, // GitHub, LinkedIn, Instagram FOLLOWER_GAINED: 10, PROJECT_STAR: 50, - CREATE_PROJECT: 200, - CREATE_DISCUSSION: 100, - EVENT_PARTICIPATION: 500, - HACKATHON_WIN: 5000, + CREATE_PROJECT: 50, + CREATE_DISCUSSION: 10, + EVENT_PARTICIPATION: 100, + HACKATHON_WIN: 1000, STREAK_BONUS_PER_DAY: 1, + PROFILE_COMPLETION: 25, + FIRST_PROJECT_UPLOAD: 50, + REPOSITORY_CONTRIBUTION: 20, + PULL_REQUEST_MERGED: 50, + ISSUE_RESOLUTION: 30, + COMMUNITY_POST_CREATION: 10, + HELPFUL_COMMENT_RECEIVED: 5, + CONSECUTIVE_WEEKLY_ACTIVITY: 100, + OPEN_SOURCE_CONTRIBUTION: 75, + MENTOR_RECOGNITION: 200, }; +export const BADGE_RARITY_POINTS = { + common: 20, + uncommon: 25, + rare: 50, + epic: 100, + legendary: 200, +} as const; + +export type BadgeRarity = keyof typeof BADGE_RARITY_POINTS; + export const LEVELS = [ { name: 'Shishya',