diff --git a/client/src/App.jsx b/client/src/App.jsx index 5a923ff..c862403 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -28,6 +28,7 @@ const ClanChiefPanel = lazy(() => import('./pages/ClanChiefPanel')); const Missions = lazy(() => import('./pages/Missions')); const PendingTasks = lazy(() => import('./pages/PendingTasks')); const Settings = lazy(() => import('./pages/Settings')); +const Badges = lazy(() => import('./pages/Badges')); const NotFound = lazy(() => import('./pages/NotFound')); const Resources = lazy(() => import('./pages/Resources')); const PendingAssignment = lazy(() => import('./pages/PendingAssignment')); @@ -81,6 +82,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/components/BadgeMedal.jsx b/client/src/components/BadgeMedal.jsx new file mode 100644 index 0000000..7b9013c --- /dev/null +++ b/client/src/components/BadgeMedal.jsx @@ -0,0 +1,75 @@ +import React from 'react'; + +const BadgeMedal = ({ rarity, icon, isUnlocked }) => { + let outerGradient, innerGradient, shadowColor, ribbonColor; + + switch(rarity) { + case 'LEGENDARY': + outerGradient = 'from-yellow-600 via-yellow-300 to-yellow-600'; + innerGradient = 'from-yellow-900 to-black'; + shadowColor = 'shadow-yellow-500/50'; + ribbonColor = 'bg-yellow-500'; + break; + case 'EPIC': + outerGradient = 'from-purple-600 via-purple-300 to-purple-600'; + innerGradient = 'from-purple-900 to-black'; + shadowColor = 'shadow-purple-500/50'; + ribbonColor = 'bg-purple-500'; + break; + case 'RARE': + outerGradient = 'from-blue-600 via-blue-300 to-blue-600'; + innerGradient = 'from-blue-900 to-black'; + shadowColor = 'shadow-blue-500/50'; + ribbonColor = 'bg-blue-500'; + break; + case 'COMMON': + outerGradient = 'from-green-600 via-green-300 to-green-600'; + innerGradient = 'from-green-900 to-black'; + shadowColor = 'shadow-green-500/50'; + ribbonColor = 'bg-green-500'; + break; + default: + outerGradient = 'from-gray-500 via-gray-200 to-gray-500'; + innerGradient = 'from-gray-800 to-black'; + shadowColor = 'shadow-gray-500/50'; + ribbonColor = 'bg-gray-500'; + break; + } + + if (!isUnlocked) { + outerGradient = 'from-gray-800 via-gray-600 to-gray-800'; + innerGradient = 'from-gray-900 to-black'; + shadowColor = 'shadow-black/50'; + ribbonColor = 'bg-gray-700'; + } + + return ( +
+ {/* Glossy Overlay for realistic 3D feel */} +
+ + {/* Ribbon (Optional: a small tab at the top) */} +
+ + {/* The 3D Coin/Medal Shape */} +
+ + {/* Inner engraved section */} +
+ + {/* The Icon */} +
+ {icon} +
+
+
+ + {/* 3D Base/Stand underneath */} +
+
+ ); +}; + +export default BadgeMedal; diff --git a/client/src/components/NotificationListener.jsx b/client/src/components/NotificationListener.jsx index ee6d6dc..22e00b9 100644 --- a/client/src/components/NotificationListener.jsx +++ b/client/src/components/NotificationListener.jsx @@ -3,6 +3,8 @@ import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { useSocket } from '../hooks/useSocket'; import { FiZap, FiAward } from 'react-icons/fi'; +import { api } from '../lib/api'; +import { useAuth } from '../context/useAuth'; const NotificationListener = () => { const navigate = useNavigate(); @@ -32,8 +34,99 @@ const NotificationListener = () => { }); // Listen for leaderboard updates (optional: show a generic toast or just let the page handle it) - useSocket('leaderboard_update', (data) => { - console.log('Leaderboard updated in real-time', data); + useSocket('leaderboard_update', () => { + // Other components (like Leaderboard/Clans) will refetch automatically + }); + + // Listen for points updates (e.g. when a submission is accepted/reverted) + const { user, refreshMe } = useAuth(); + + // Track previously unlocked badges to detect new ones + const unlockedBadgesRef = React.useRef(new Set()); + + // Function to check badges + const checkBadges = React.useCallback(async (notify = false) => { + if (!user) return; + try { + const res = await api.get('/api/badges'); + const badges = res.data?.data || []; + const newlyUnlocked = []; + + badges.forEach(b => { + if (b.isUnlocked) { + if (notify && !unlockedBadgesRef.current.has(b.name)) { + newlyUnlocked.push(b); + } + unlockedBadgesRef.current.add(b.name); + } else { + unlockedBadgesRef.current.delete(b.name); + } + }); + + if (notify && newlyUnlocked.length > 0) { + newlyUnlocked.forEach(badge => { + toast((t) => ( +
{ + toast.dismiss(t.id); + navigate('/badges'); + }} + > +
{badge.icon}
+
+

Badge Unlocked!

+

{badge.name}

+
+
+ ), { + duration: 8000, + position: 'top-center', + style: { + background: '#0f1115', + border: '1px solid rgba(168,85,247,0.4)', + boxShadow: '0 0 30px rgba(168,85,247,0.2)', + } + }); + }); + } + // eslint-disable-next-line no-unused-vars + } catch (e) { + // ignore + } + }, [user, navigate]); + + // Initial load + React.useEffect(() => { + checkBadges(false); + }, [checkBadges]); + + useSocket('points_update', (data) => { + if (user && data.userId === user.id) { + if (data.status === 'Accepted') { + toast.success('Your submission was Accepted! XP awarded.', { + duration: 5000, + style: { + background: '#0f1115', + color: '#22c55e', + border: '1px solid rgba(34,197,94,0.3)', + }, + icon: '✅', + }); + checkBadges(true); // Check for new badges! + } else if (data.status === 'Reverted') { + toast.error('A previously accepted submission was reverted.', { + duration: 5000, + style: { + background: '#0f1115', + color: '#ef4444', + border: '1px solid rgba(239,68,68,0.3)', + }, + }); + checkBadges(false); // Update ref but don't notify on revert + } + refreshMe(); + } }); return null; diff --git a/client/src/components/ProfileSidebar.jsx b/client/src/components/ProfileSidebar.jsx index b80fc9e..b67c2f9 100644 --- a/client/src/components/ProfileSidebar.jsx +++ b/client/src/components/ProfileSidebar.jsx @@ -165,6 +165,10 @@ const ProfileSidebar = ({ user, summary, profile, badges }) => { }); }; + const clanBadgeCount = React.useMemo(() => { + return (badges || []).filter(b => b.isChiefBadge && b.isUnlocked).length; + }, [badges]); + const sortedBadges = React.useMemo(() => { const baseBadges = badges?.length ? badges @@ -326,12 +330,13 @@ const ProfileSidebar = ({ user, summary, profile, badges }) => { {/* Divider */}
- {/* Stats 2×2 grid */} -
+ {/* Stats grid */} +
0 ? `best ${maxStreak}d` : undefined} /> +
{/* Divider */} diff --git a/client/src/pages/Badges.jsx b/client/src/pages/Badges.jsx index 1c4ff0b..2d56b25 100644 --- a/client/src/pages/Badges.jsx +++ b/client/src/pages/Badges.jsx @@ -1,72 +1,394 @@ -import React from 'react'; -import { useQuery } from '@tanstack/react-query'; +import React, { useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../lib/api'; import PageHeader from '../components/PageHeader'; -import BaseCard from '../components/BaseCard'; import SkeletonCard from '../components/SkeletonCard'; -import { FiLock } from 'react-icons/fi'; +import { motion, AnimatePresence } from 'framer-motion'; +import { FiLock, FiCheck, FiAward, FiFilter, FiStar } from 'react-icons/fi'; +import toast from 'react-hot-toast'; +import BadgeMedal from '../components/BadgeMedal'; -const Badges = () => { +// ── Rarity config ──────────────────────────────────────────────────────────── +const RARITY = { + COMMON: { glow: "0,0,0,0", border: "#334155", lightBorder: "#475569", bg: "#1e293b", label: "#94a3b8" }, + RARE: { glow: "59,130,246,0.5", border: "#3b82f6", lightBorder: "#2563eb", bg: "#1e3a5f", label: "#60a5fa" }, + EPIC: { glow: "168,85,247,0.55", border: "#a855f7", lightBorder: "#7e22ce", bg: "#3b1f6e", label: "#c084fc" }, + LEGENDARY: { glow: "250,204,21,0.65", border: "#facc15", lightBorder: "#a16207", bg: "#422006", label: "#fde047" }, +}; + +// ── Earn-difficulty config ─────────────────────────────────────────────────── +const DIFFICULTY_CONFIG = { + Easy: { label: 'Easy', cls: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25' }, + Medium: { label: 'Medium', cls: 'bg-amber-500/15 text-amber-400 border-amber-500/25' }, + Hard: { label: 'Hard', cls: 'bg-rose-500/15 text-rose-400 border-rose-500/25' }, + Elite: { label: 'Elite', cls: 'bg-violet-500/15 text-violet-400 border-violet-500/25' }, +}; + +// ── Category order ─────────────────────────────────────────────────────────── +const CATEGORY_ORDER = ['Milestones', 'Streaks', 'Difficulty', 'Precision', 'Languages', 'Special', "Chief's Choice"]; + +// ── Helper: hex progress bar ───────────────────────────────────────────────── +const ProgressBar = ({ progress, threshold }) => { + const pct = threshold > 0 ? Math.min(100, Math.round((progress / threshold) * 100)) : 0; + return ( +
+
+ {progress} / {threshold} + {pct}% +
+
+
+
+
+ ); +}; + +// ── Individual Badge Card ───────────────────────────────────────────────────── +const BadgeCard = ({ badge, index, onSetFeatured, isFeatured }) => { + const isUnlocked = badge.isUnlocked; + const isChief = badge.isChiefBadge; + const r = RARITY[badge.rarity] || RARITY.COMMON; + const diff = DIFFICULTY_CONFIG[badge.earnDifficulty] || DIFFICULTY_CONFIG.Medium; + + return ( + + {/* Dynamic background rarity glow */} + {isUnlocked && ( +
+ )} + {/* Glossy top edge highlight */} + {isUnlocked && ( +
+ )} + {/* Chief crown badge */} + {isChief && ( +
+ + ★ Chief + +
+ )} + + {/* 3D Medal Component with a pedestal effect */} +
+ {/* Subtle ground shadow for the medal */} + {isUnlocked &&
} + +
+ + {/* Content */} +
+

+ {badge.name} +

+ +

+ {badge.description} +

+ + {/* Progress bar (locked only) */} + {!isUnlocked && !isChief && badge.threshold > 1 && ( +
+ +
+ )} + + {/* Chief locked */} + {!isUnlocked && isChief && ( +

Awarded by Clan Chief

+ )} + + {/* Footer tags */} +
+ + {badge.rarity} + + {!isChief && ( + + {diff.label} + + )} +
+
+ + {/* Unlocked checkmark overlay styled like a gem */} + {isUnlocked && ( +
+
+ +
+ + +
+ )} + + ); +}; + +// ── Category Section ────────────────────────────────────────────────────────── +const CategorySection = ({ category, badges, index, onSetFeatured, featuredBadgeId }) => { + + const unlocked = badges.filter(b => b.isUnlocked).length; + const isComplete = unlocked === badges.length; + + const CATEGORY_ICONS = { + 'Milestones': '🎯', + 'Streaks': '🔥', + 'Difficulty': '⚡', + 'Precision': '🎯', + 'Languages': '🌐', + 'Special': '✨', + "Chief's Choice":'👑', + }; + + return ( + + {/* Section header */} +
+ {CATEGORY_ICONS[category] || '🏅'} +

{category}

+
+ + {unlocked} / {badges.length} + + {isComplete && } +
+
+
+ +
+ {badges.map((badge, i) => ( + + ))} +
+ + ); +}; + +// ── Main Badges Page ────────────────────────────────────────────────────────── +const Badges = () => { + const queryClient = useQueryClient(); + const [activeCategory, setActiveCategory] = useState('All'); + const [showUnlocked, setShowUnlocked] = useState(null); // null=all, true=unlocked, false=locked const { data: badges = [], isLoading } = useQuery({ queryKey: ['badges'], queryFn: async () => { const res = await api.get('/api/badges'); return res.data.data; - } + }, + staleTime: 60000, }); - const getRarityColor = (rarity) => { - switch(rarity) { - case 'LEGENDARY': return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; - case 'EPIC': return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; - case 'RARE': return 'text-blue-400 bg-blue-400/10 border-blue-400/30'; - case 'COMMON': return 'text-green-400 bg-green-400/10 border-green-400/30'; - default: return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; - } - }; + // We actually need the user's featuredBadge ID. + // We can fetch it via /api/profile/stats which returns user info or /api/auth/me + // Wait, the currently logged in user is in auth context. But we can just use the auth/me endpoint or profile + const { data: authUser } = useQuery({ + queryKey: ['authUser'], + queryFn: async () => { + const res = await api.get('/api/auth/me'); + return res.data.data; + }, + staleTime: 60000, + }); + + // featuredBadge may be a populated object or a raw ID string + const featuredBadgeId = authUser?.featuredBadge?._id + ? authUser.featuredBadge._id.toString() + : authUser?.featuredBadge?.toString() || null; + + const setFeaturedMutation = useMutation({ + mutationFn: async (badgeId) => { + await api.put('/api/profile/featured-badge', { badgeId }); + }, + onSuccess: () => { + toast.success('Featured badge updated!'); + queryClient.invalidateQueries(['authUser']); + queryClient.invalidateQueries(['profile-stats']); + }, + onError: (err) => toast.error(err.response?.data?.message || 'Failed to update featured badge'), + }); + + // Group badges by category + const groupedBadges = useMemo(() => { + const groups = {}; + badges.forEach(b => { + const cat = b.isChiefBadge ? "Chief's Choice" : (b.category || 'Special'); + if (!groups[cat]) groups[cat] = []; + groups[cat].push(b); + }); + return groups; + }, [badges]); + + // Categories for filter tabs + const categories = useMemo(() => { + return ['All', ...CATEGORY_ORDER.filter(c => groupedBadges[c])]; + }, [groupedBadges]); + + // Stats + const totalUnlocked = badges.filter(b => b.isUnlocked).length; + const totalBadges = badges.length; + const pct = totalBadges > 0 ? Math.round((totalUnlocked / totalBadges) * 100) : 0; + + // Filtered groups + const filteredGroups = useMemo(() => { + const result = {}; + Object.entries(groupedBadges).forEach(([cat, catBadges]) => { + if (activeCategory !== 'All' && cat !== activeCategory) return; + const filtered = showUnlocked === null ? catBadges : catBadges.filter(b => b.isUnlocked === showUnlocked); + if (filtered.length > 0) result[cat] = filtered; + }); + return result; + }, [groupedBadges, activeCategory, showUnlocked]); return ( -
+
+ {/* Page Header */} - {isLoading ? ( -
- + {/* Progress Overview Card */} + {!isLoading && ( +
+
+
+
+ +
+
+

{totalUnlocked} / {totalBadges}

+

Badges unlocked

+
+
+
+
+ Overall Progress + {pct}% +
+
+
+
+
+
- ) : ( -
- {badges.map((badge) => { - const isUnlocked = badge.isUnlocked; - - return ( - + {/* Category tabs */} +
+ {categories.map(cat => ( + + ))} +
+ + {/* Status filter */} +
+ {[ + { label: 'All', val: null }, + { label: '✓ Earned', val: true }, + { label: '🔒 Locked', val: false }, + ].map(({ label, val }) => ( + + ))} +
)} + + {/* Badge Grid */} + {isLoading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => )} +
+ ) : ( + +
+ {CATEGORY_ORDER.filter(cat => filteredGroups[cat]).map((cat, idx) => ( + + ))} + {Object.keys(filteredGroups).length === 0 && ( +
+ +

No badges match this filter.

+
+ )} +
+
+ )}
); }; diff --git a/client/src/pages/ClanChiefPanel.jsx b/client/src/pages/ClanChiefPanel.jsx index e203372..a414d63 100644 --- a/client/src/pages/ClanChiefPanel.jsx +++ b/client/src/pages/ClanChiefPanel.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; -import { FiUsers, FiActivity, FiShield, FiFileText, FiBell, FiAlertCircle } from 'react-icons/fi'; +import { FiUsers, FiActivity, FiShield, FiFileText, FiBell, FiAlertCircle, FiAward } from 'react-icons/fi'; import { clsx } from 'clsx'; import { api } from '../lib/api'; @@ -10,14 +10,16 @@ import PermissionLegend from '../components/PermissionLegend'; import ChiefDashboardTab from './chief/ChiefDashboardTab'; import ChiefMembersTab from './chief/ChiefMembersTab'; import ChiefReviewTab from './chief/ChiefReviewTab'; +import ChiefBadgesTab from './chief/ChiefBadgesTab'; const ClanChiefPanel = () => { const [activeTab, setActiveTab] = useState('dashboard'); const tabs = [ - { id: 'dashboard', label: 'Clan Overview', icon: FiActivity }, - { id: 'members', label: 'Member Roster', icon: FiUsers }, - { id: 'review', label: 'Review Submissions', icon: FiFileText }, + { id: 'dashboard', label: 'Clan Overview', icon: FiActivity }, + { id: 'members', label: 'Member Roster', icon: FiUsers }, + { id: 'review', label: 'Review Submissions', icon: FiFileText }, + { id: 'badges', label: 'Award Badges', icon: FiAward }, ]; const chiefQuery = useQuery({ @@ -112,8 +114,9 @@ const ClanChiefPanel = () => { ) : ( <> {activeTab === 'dashboard' && } - {activeTab === 'members' && } - {activeTab === 'review' && } + {activeTab === 'members' && } + {activeTab === 'review' && } + {activeTab === 'badges' && } )} diff --git a/client/src/pages/Clans.jsx b/client/src/pages/Clans.jsx index 5283bda..c07358c 100644 --- a/client/src/pages/Clans.jsx +++ b/client/src/pages/Clans.jsx @@ -59,6 +59,38 @@ const ClanDashboard = ({ clan, userId, onLeave, readOnly, onBack }) => { const isArchived = clan.status === 'archived'; const [viewMode, setViewMode] = useState('grid'); + // Fetch member badges to determine Star Performer + const { data: memberBadgeMap = {} } = useQuery({ + queryKey: ['member-badges', clan?._id], + queryFn: async () => { + if (!members.length) return {}; + const results = await Promise.all( + members.map(async m => { + try { + const res = await api.get(`/api/badges/user/${m._id}`); + const chiefAwarded = (res.data.data || []).filter(b => b.isChiefBadge && b.isUnlocked); + return [m._id, chiefAwarded.length]; + } catch { return [m._id, 0]; } + }) + ); + return Object.fromEntries(results); + }, + enabled: !!members.length, + staleTime: 60000, + }); + + const starPerformerId = React.useMemo(() => { + let maxBadges = 0; + let starId = null; + for (const [memId, count] of Object.entries(memberBadgeMap)) { + if (count > maxBadges) { + maxBadges = count; + starId = memId; + } + } + return maxBadges > 0 ? starId : null; + }, [memberBadgeMap]); + return (
{/* Header Banner */} @@ -176,6 +208,16 @@ const ClanDashboard = ({ clan, userId, onLeave, readOnly, onBack }) => { 🔥 {member.streak || 0} + {memberBadgeMap[member._id] > 0 && ( + + {memberBadgeMap[member._id]} + + )} + {starPerformerId === member._id && ( + + ✨ Star Performer + + )} {isMemberChief ? ( CHIEF diff --git a/client/src/pages/chief/ChiefBadgesTab.jsx b/client/src/pages/chief/ChiefBadgesTab.jsx new file mode 100644 index 0000000..df1f70b --- /dev/null +++ b/client/src/pages/chief/ChiefBadgesTab.jsx @@ -0,0 +1,289 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import { motion, AnimatePresence } from 'framer-motion'; +import { FiAward, FiX, FiCheck, FiSearch, FiUser } from 'react-icons/fi'; +import toast from 'react-hot-toast'; + +const RARITY_CONFIG = { + COMMON: { text: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20' }, + RARE: { text: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' }, + EPIC: { text: 'text-purple-400', bg: 'bg-purple-500/10 border-purple-500/20' }, + LEGENDARY: { text: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20' }, +}; + +// ── Award Badge Modal ───────────────────────────────────────────────────────── +const AwardBadgeModal = ({ member, chiefBadges, onClose, onAward }) => { + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + + const handleAward = async () => { + if (!selected) return; + setLoading(true); + await onAward(member._id, selected._id); + setLoading(false); + onClose(); + }; + + return ( +
+
+ e.stopPropagation()} + > + {/* Header */} +
+
+ +
+
+

Award Chief Badge

+

+ To: {member.name || member.username} +

+
+ +
+ + {/* Badge Picker */} +
+

Select a Badge

+
+ {chiefBadges.map(badge => { + const r = RARITY_CONFIG[badge.rarity] || RARITY_CONFIG.COMMON; + const isSelected = selected?._id === badge._id; + return ( + + ); + })} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +// ── Main Component ──────────────────────────────────────────────────────────── +const ChiefBadgesTab = ({ clan }) => { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(''); + const [awardingMember, setAwardingMember] = useState(null); + + // Fetch chief badge pool + const { data: chiefBadges = [] } = useQuery({ + queryKey: ['chief-badge-pool'], + queryFn: async () => { + const res = await api.get('/api/badges/chief'); + return res.data.data; + }, + }); + + // Fetch member badges + const { data: memberBadgeMap = {}, isLoading } = useQuery({ + queryKey: ['member-badges', clan?._id], + queryFn: async () => { + if (!clan?.members?.length) return {}; + const results = await Promise.all( + clan.members.map(async m => { + try { + const res = await api.get(`/api/badges/user/${m._id}`); + const chiefAwarded = (res.data.data || []).filter(b => b.isChiefBadge && b.isUnlocked); + return [m._id, chiefAwarded]; + } catch { return [m._id, []]; } + }) + ); + return Object.fromEntries(results); + }, + enabled: !!clan?.members?.length, + }); + + const awardMutation = useMutation({ + mutationFn: async ({ userId, badgeId }) => { + await api.post(`/api/badges/award/${userId}`, { badgeId }); + }, + onSuccess: () => { + toast.success('Badge awarded successfully! 🏆'); + queryClient.invalidateQueries({ queryKey: ['member-badges'] }); + }, + onError: (err) => toast.error(err.response?.data?.message || 'Failed to award badge.'), + }); + + const revokeMutation = useMutation({ + mutationFn: async ({ userId, badgeId }) => { + await api.delete(`/api/badges/revoke/${userId}/${badgeId}`); + }, + onSuccess: () => { + toast.success('Badge revoked.'); + queryClient.invalidateQueries({ queryKey: ['member-badges'] }); + }, + onError: (err) => toast.error(err.response?.data?.message || 'Failed to revoke badge.'), + }); + + const members = (clan?.members || []).filter(m => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return (m.name || '').toLowerCase().includes(q) || (m.username || '').toLowerCase().includes(q); + }); + + return ( +
+ {/* Header */} +
+
+

+ + Award Chief Badges +

+

+ Recognize outstanding members with honorary badges. These badges appear permanently on their profile. +

+
+
+ + setSearch(e.target.value)} + /> +
+
+ + {/* Chief badge pool preview */} +
+ {chiefBadges.map(b => ( +
+ {b.icon} + {b.name} +
+ ))} +
+ + {/* Member list */} + {isLoading ? ( +
+
+
+ ) : ( +
+ {members.map(member => { + const awarded = memberBadgeMap[member._id] || []; + return ( +
+ {/* Avatar */} +
+
+ {member.profilePicture + ? {member.username} + : + } +
+
+

{member.name || member.username}

+

@{member.username}

+
+
+ + {/* Awarded badges */} +
+ {awarded.length === 0 && ( + No chief badges yet + )} + {awarded.map(badge => ( +
+ {badge.icon} + {badge.name} + +
+ ))} +
+ + {/* Award button */} + +
+ ); + })} + + {members.length === 0 && ( +
+ +

No members found.

+
+ )} +
+ )} + + {/* Award modal */} + + {awardingMember && ( + setAwardingMember(null)} + onAward={async (userId, badgeId) => { + await awardMutation.mutateAsync({ userId, badgeId }); + }} + /> + )} + +
+ ); +}; + +export default ChiefBadgesTab; diff --git a/server/src/features/auth/auth.controller.js b/server/src/features/auth/auth.controller.js index 6ba7b5a..76fd14d 100644 --- a/server/src/features/auth/auth.controller.js +++ b/server/src/features/auth/auth.controller.js @@ -549,7 +549,7 @@ const logoutAll = async (req, res, next) => { const getMe = async (req, res, next) => { try { - const user = await User.findById(req.user.id).populate('clan', 'name').lean(); + const user = await User.findById(req.user.id).populate('clan', 'name').populate('featuredBadge').lean(); if (!user) { res.status(404); throw new Error('User not found'); diff --git a/server/src/features/badges/Badge.model.js b/server/src/features/badges/Badge.model.js index 4b2525b..fb7d5b6 100644 --- a/server/src/features/badges/Badge.model.js +++ b/server/src/features/badges/Badge.model.js @@ -1,11 +1,13 @@ const mongoose = require('mongoose'); const badgeSchema = new mongoose.Schema({ - name: { type: String, required: true }, + name: { type: String, required: true, unique: true }, icon: { type: String, required: true }, - color: { type: String, enum: ['blue', 'gold', 'green', 'red', 'purple'], default: 'blue' }, + color: { type: String, enum: ['blue', 'gold', 'green', 'red', 'purple', 'orange', 'cyan', 'pink'], default: 'blue' }, rarity: { type: String, enum: ['COMMON', 'RARE', 'EPIC', 'LEGENDARY'], default: 'COMMON' }, - description: { type: String } + description: { type: String }, + earnDifficulty: { type: String, enum: ['Easy', 'Medium', 'Hard', 'Elite'], default: 'Medium' }, + isChiefBadge: { type: Boolean, default: false }, // manually awarded by clan chief only }, { timestamps: true }); module.exports = mongoose.model('Badge', badgeSchema); diff --git a/server/src/features/badges/badge.controller.js b/server/src/features/badges/badge.controller.js index c88755a..85ef908 100644 --- a/server/src/features/badges/badge.controller.js +++ b/server/src/features/badges/badge.controller.js @@ -1,14 +1,101 @@ const { getAllBadgesForUser } = require('./badge.service'); +const Badge = require('./Badge.model'); +const User = require('../users/User.model'); -// @desc Get all badges +// @desc Get all badges for the logged-in user // @route GET /api/badges // @access Private exports.getBadges = async (req, res, next) => { try { - const userId = req.user.id; - const data = await getAllBadgesForUser(userId); + const data = await getAllBadgesForUser(req.user.id); res.status(200).json({ success: true, data }); } catch (error) { next(error); } }; + +// @desc Get all badges for another user (for profile view) +// @route GET /api/badges/user/:userId +// @access Private +exports.getBadgesForUser = async (req, res, next) => { + try { + const data = await getAllBadgesForUser(req.params.userId); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } +}; + +// @desc Award a chief badge to a clan member +// @route POST /api/badges/award/:userId +// @access Private (clan-chief or admin) +exports.awardBadge = async (req, res, next) => { + try { + const { badgeId } = req.body; + const { userId } = req.params; + + if (!badgeId) return res.status(400).json({ success: false, message: 'badgeId is required.' }); + + const badge = await Badge.findById(badgeId); + if (!badge || !badge.isChiefBadge) { + return res.status(400).json({ success: false, message: 'Invalid badge or not a Chief badge.' }); + } + + // Verify the chief is awarding within their own clan + const chief = await User.findById(req.user.id).select('clan role'); + const member = await User.findById(userId).select('clan'); + if (!member) return res.status(404).json({ success: false, message: 'User not found.' }); + + if (chief.role !== 'admin' && chief.role !== 'superAdmin') { + if (!chief.clan || !member.clan || chief.clan.toString() !== member.clan.toString()) { + return res.status(403).json({ success: false, message: 'You can only award badges to members of your own clan.' }); + } + } + + await User.findByIdAndUpdate(userId, { $addToSet: { awardedBadgeIds: badgeId } }); + res.status(200).json({ success: true, message: `Badge "${badge.name}" awarded successfully.` }); + } catch (error) { + next(error); + } +}; + +// @desc Revoke a chief badge from a clan member +// @route DELETE /api/badges/revoke/:userId/:badgeId +// @access Private (clan-chief or admin) +exports.revokeBadge = async (req, res, next) => { + try { + const { userId, badgeId } = req.params; + + const badge = await Badge.findById(badgeId); + if (!badge || !badge.isChiefBadge) { + return res.status(400).json({ success: false, message: 'Invalid badge or not a Chief badge.' }); + } + + const chief = await User.findById(req.user.id).select('clan role'); + const member = await User.findById(userId).select('clan'); + if (!member) return res.status(404).json({ success: false, message: 'User not found.' }); + + if (chief.role !== 'admin' && chief.role !== 'superAdmin') { + if (!chief.clan || !member.clan || chief.clan.toString() !== member.clan.toString()) { + return res.status(403).json({ success: false, message: 'You can only manage badges for members of your own clan.' }); + } + } + + await User.findByIdAndUpdate(userId, { $pull: { awardedBadgeIds: badgeId } }); + res.status(200).json({ success: true, message: `Badge "${badge.name}" revoked.` }); + } catch (error) { + next(error); + } +}; + +// @desc Get all chief badges (for picker UI) +// @route GET /api/badges/chief +// @access Private +exports.getChiefBadges = async (req, res, next) => { + try { + const badges = await Badge.find({ isChiefBadge: true }).sort({ rarity: -1 }); + res.status(200).json({ success: true, data: badges }); + } catch (error) { + next(error); + } +}; diff --git a/server/src/features/badges/badge.routes.js b/server/src/features/badges/badge.routes.js index a588e70..f855c34 100644 --- a/server/src/features/badges/badge.routes.js +++ b/server/src/features/badges/badge.routes.js @@ -1,8 +1,21 @@ const express = require('express'); const router = express.Router(); -const { getBadges } = require('./badge.controller'); +const { getBadges, getBadgesForUser, awardBadge, revokeBadge, getChiefBadges } = require('./badge.controller'); const { protect } = require('../../../middleware/auth'); -router.route('/').get(protect, getBadges); +// Get own badges +router.get('/', protect, getBadges); + +// Get chief badge pool +router.get('/chief', protect, getChiefBadges); + +// Get badges for another user (profile) +router.get('/user/:userId', protect, getBadgesForUser); + +// Award a chief badge to a member +router.post('/award/:userId', protect, awardBadge); + +// Revoke a chief badge from a member +router.delete('/revoke/:userId/:badgeId', protect, revokeBadge); module.exports = router; diff --git a/server/src/features/badges/badge.service.js b/server/src/features/badges/badge.service.js index 91d1834..1ea41f1 100644 --- a/server/src/features/badges/badge.service.js +++ b/server/src/features/badges/badge.service.js @@ -5,64 +5,53 @@ const User = require('../users/User.model'); /** * Computes all badges with isUnlocked status for a user. - * @param {string|mongoose.Types.ObjectId} userId - * @returns {Promise} Badge documents with isUnlocked flag + * Chief-awarded badges (awardedBadgeIds) are auto-unlocked. */ const getAllBadgesForUser = async (userId) => { const [badges, acceptedSubmissions, allSubmissions, userObj] = await Promise.all([ - Badge.find().sort('rarity'), + Badge.find().sort({ rarity: 1, name: 1 }), Submission.find({ userId, status: 'Accepted' }).populate('challengeId'), Submission.find({ userId }).sort({ submittedAt: 1 }), - User.findById(userId).select('createdAt') + User.findById(userId).select('createdAt awardedBadgeIds') ]); if (!userObj) return []; + // Awarded badge IDs set (chief-granted) + const awardedIds = new Set((userObj.awardedBadgeIds || []).map(id => id.toString())); + // Group submissions by challenge const challengeAttempts = {}; const firstSubmissions = {}; allSubmissions.forEach(sub => { const chId = sub.challengeId?.toString(); if (!chId) return; - if (!challengeAttempts[chId]) { challengeAttempts[chId] = { count: 0, rejectedCountBeforeAccept: 0, hasAccepted: false }; } - if (!challengeAttempts[chId].hasAccepted) { challengeAttempts[chId].count++; - if (sub.status === 'Rejected') { - challengeAttempts[chId].rejectedCountBeforeAccept++; - } - if (sub.status === 'Accepted') { - challengeAttempts[chId].hasAccepted = true; - } - } - - if (!firstSubmissions[chId]) { - firstSubmissions[chId] = sub.status; + if (sub.status === 'Rejected') challengeAttempts[chId].rejectedCountBeforeAccept++; + if (sub.status === 'Accepted') challengeAttempts[chId].hasAccepted = true; } + if (!firstSubmissions[chId]) firstSubmissions[chId] = sub.status; }); - const flawlessCount = Object.values(firstSubmissions).filter(status => status === 'Accepted').length; + const flawlessCount = Object.values(firstSubmissions).filter(s => s === 'Accepted').length; const maxRejectedBeforeAccept = Math.max(0, ...Object.values(challengeAttempts).filter(c => c.hasAccepted).map(c => c.rejectedCountBeforeAccept)); const hasFirstReject = allSubmissions.some(s => s.status === 'Rejected'); - + // Difficulty Breakdown const diffCount = { Easy: 0, Medium: 0, Hard: 0 }; const languageCount = {}; - - // To count unique accepted challenges const uniqueAcceptedChIds = new Set(); acceptedSubmissions.forEach(sub => { const chId = sub.challengeId?._id?.toString(); if (!chId || uniqueAcceptedChIds.has(chId)) return; uniqueAcceptedChIds.add(chId); - const diff = sub.challengeId?.difficulty; if (diff) diffCount[diff] = (diffCount[diff] || 0) + 1; - const lang = sub.language?.toLowerCase() || 'javascript'; languageCount[lang] = (languageCount[lang] || 0) + 1; }); @@ -70,137 +59,194 @@ const getAllBadgesForUser = async (userId) => { const totalUniqueAccepted = uniqueAcceptedChIds.size; const polyglotCount = Object.keys(languageCount).length; - // Streak & Heatmap calculation + // Streak & Heatmap const heatmapAggregation = await Submission.aggregate([ { $match: { userId: new mongoose.Types.ObjectId(userId), status: 'Accepted' } }, - { - $group: { - _id: { $dateToString: { format: '%Y-%m-%d', date: '$submittedAt' } }, - count: { $sum: 1 }, - }, - }, + { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$submittedAt' } }, count: { $sum: 1 } } }, ]); const heatmapMap = {}; - heatmapAggregation.forEach((item) => { heatmapMap[item._id] = item.count; }); + heatmapAggregation.forEach(item => { heatmapMap[item._id] = item.count; }); - let maxStreak = 0; - let tempStreak = 0; + let maxStreak = 0, tempStreak = 0; const joinDate = userObj.createdAt || new Date(); const startUTC = Date.UTC(joinDate.getUTCFullYear(), joinDate.getUTCMonth(), joinDate.getUTCDate()); - const todayUTC = new Date(); - const todayStr = todayUTC.toISOString().split('T')[0]; + const todayStr = new Date().toISOString().split('T')[0]; const heatmapData = []; + let totalWeekendSolves = 0, maxSolvesInOneDay = 0; for (let i = 0; i < 365; i++) { - const d = new Date(startUTC + (i * 24 * 60 * 60 * 1000)); + const d = new Date(startUTC + i * 86400000); const dateStr = d.toISOString().split('T')[0]; - heatmapData.push({ - date: dateStr, - count: dateStr > todayStr ? 0 : (heatmapMap[dateStr] || 0), - isWeekend: d.getUTCDay() === 0 || d.getUTCDay() === 6 - }); + const count = dateStr > todayStr ? 0 : (heatmapMap[dateStr] || 0); + heatmapData.push({ date: dateStr, count, isWeekend: d.getUTCDay() === 0 || d.getUTCDay() === 6 }); } - - let totalWeekendSolves = 0; - let maxSolvesInOneDay = 0; - heatmapData.forEach(day => { if (day.count > 0) { tempStreak++; if (tempStreak > maxStreak) maxStreak = tempStreak; if (day.isWeekend) totalWeekendSolves += day.count; - } else { - tempStreak = 0; - } + } else { tempStreak = 0; } if (day.count > maxSolvesInOneDay) maxSolvesInOneDay = day.count; }); const sortedAccepted = [...acceptedSubmissions].sort((a, b) => new Date(a.submittedAt) - new Date(b.submittedAt)); - // Time-based - const nightOwlSubmission = sortedAccepted.find(s => { - const hrs = new Date(s.submittedAt).getHours(); - return hrs >= 0 && hrs < 4; - }); - const earlyBirdSubmission = sortedAccepted.find(s => { - const hrs = new Date(s.submittedAt).getHours(); - return hrs >= 5 && hrs < 8; - }); + const nightOwlSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 0 && h < 4; }); + const earlyBirdSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 5 && h < 8; }); + const dawnCoderSubmission = sortedAccepted.find(s => { const h = new Date(s.submittedAt).getHours(); return h >= 4 && h < 5; }); - // Speedster check let speedsterUnlocked = false; for (let i = 1; i < sortedAccepted.length; i++) { - const diffMins = (new Date(sortedAccepted[i].submittedAt) - new Date(sortedAccepted[i-1].submittedAt)) / (1000 * 60); - if (diffMins <= 10) { - speedsterUnlocked = true; - break; - } + const diffMins = (new Date(sortedAccepted[i].submittedAt) - new Date(sortedAccepted[i - 1].submittedAt)) / 60000; + if (diffMins <= 10) { speedsterUnlocked = true; break; } } - // Receive Feedback const hasReviewee = allSubmissions.some(s => s.feedback && s.feedback.trim() !== ''); - - // Perfectionist (100% acceptance rate with at least 10 accepted) const perfectionistUnlocked = totalUniqueAccepted >= 10 && allSubmissions.every(s => s.status === 'Accepted'); + // ============================================================ + // UNLOCK MAP — 35+ badges + // ============================================================ const unlockedMap = { // Milestones - 'First Blood': totalUniqueAccepted >= 1, - 'Code Novice': totalUniqueAccepted >= 10, - 'Code Adept': totalUniqueAccepted >= 50, - 'Algorithm Master': totalUniqueAccepted >= 100, - 'Code Sensei': totalUniqueAccepted >= 150, - 'Ascended': totalUniqueAccepted >= 250, + 'First Blood': totalUniqueAccepted >= 1, + 'Code Rookie': totalUniqueAccepted >= 5, + 'Code Novice': totalUniqueAccepted >= 10, + 'Challenger': totalUniqueAccepted >= 25, + 'Code Adept': totalUniqueAccepted >= 50, + 'Algorithm Master': totalUniqueAccepted >= 100, + 'Code Sensei': totalUniqueAccepted >= 150, + 'Ascended': totalUniqueAccepted >= 250, + 'The Legend': totalUniqueAccepted >= 500, // Difficulty Mastery - 'Easy Peasy': diffCount.Easy >= 10, - 'Warmup Complete': diffCount.Easy >= 25, - 'Stepping Up': diffCount.Medium >= 10, - 'Midweight Champ': diffCount.Medium >= 25, - 'Grandmaster': diffCount.Hard >= 1, - 'Abyss Walker': diffCount.Hard >= 10, - - // Streak & Consistency - 'Habit Builder': maxStreak >= 3, - 'Streak Warrior': maxStreak >= 5, - 'Unstoppable': maxStreak >= 14, - 'Lunar Cycle': maxStreak >= 30, - 'Weekend Warrior': totalWeekendSolves >= 5, - 'Night Owl': !!nightOwlSubmission, - 'Early Bird': !!earlyBirdSubmission, + 'Easy Peasy': diffCount.Easy >= 10, + 'Warmup Complete': diffCount.Easy >= 25, + 'Stepping Up': diffCount.Medium >= 10, + 'Midweight Champ': diffCount.Medium >= 25, + 'Grandmaster': diffCount.Hard >= 1, + 'Abyss Walker': diffCount.Hard >= 10, + 'Elite Solver': diffCount.Hard >= 25, + + // Streaks & Consistency + 'Solo Sprint': maxStreak >= 1, + 'Habit Builder': maxStreak >= 3, + 'Streak Warrior': maxStreak >= 5, + 'Consistent Coder': maxStreak >= 7, + 'Unstoppable': maxStreak >= 14, + 'Lunar Cycle': maxStreak >= 30, + 'Night Owl': !!nightOwlSubmission, + 'Early Bird': !!earlyBirdSubmission, + 'Dawn Coder': !!dawnCoderSubmission, + 'Weekend Warrior': totalWeekendSolves >= 5, // Precision & Accuracy - 'Flawless': flawlessCount >= 1, - 'Sharpshooter': flawlessCount >= 10, - 'Eagle Eye': flawlessCount >= 50, - 'Resilient': maxRejectedBeforeAccept >= 5, - 'Never Surrender': maxRejectedBeforeAccept >= 10, - 'Trial & Error': hasFirstReject, + 'Trial & Error': hasFirstReject, + 'Flawless': flawlessCount >= 1, + 'Resilient': maxRejectedBeforeAccept >= 5, + 'Sharpshooter': flawlessCount >= 10, + 'Never Surrender': maxRejectedBeforeAccept >= 10, + 'Comeback King': maxRejectedBeforeAccept >= 3, + 'Eagle Eye': flawlessCount >= 50, // Language Diversity - 'Polyglot': polyglotCount >= 3, - 'JS Ninja': (languageCount['javascript'] || languageCount['js']) >= 10, - 'Pythonista': (languageCount['python'] || languageCount['py']) >= 10, - 'Java Juggernaut': languageCount['java'] >= 10, - 'C/C++ Hacker': (languageCount['cpp'] || languageCount['c'] || languageCount['c++']) >= 10, - - // Miscellaneous - 'Reviewee': hasReviewee, - 'Perfectionist': perfectionistUnlocked, - 'Speedster': speedsterUnlocked, - 'Marathon': maxSolvesInOneDay >= 5 + 'Polyglot': polyglotCount >= 3, + 'JS Ninja': (languageCount['javascript'] || languageCount['js'] || 0) >= 10, + 'Pythonista': (languageCount['python'] || languageCount['py'] || 0) >= 10, + 'Java Juggernaut': (languageCount['java'] || 0) >= 10, + 'C/C++ Hacker': (languageCount['cpp'] || languageCount['c'] || languageCount['c++'] || 0) >= 10, + 'Polyglot Pro': polyglotCount >= 5, + + // Special + 'Reviewee': hasReviewee, + 'Perfectionist': perfectionistUnlocked, + 'Speedster': speedsterUnlocked, + 'Marathon': maxSolvesInOneDay >= 5, + 'Swift Fingers': maxSolvesInOneDay >= 10, + }; + + // ============================================================ + // BADGE METADATA — category, progress, threshold, earnDifficulty + // ============================================================ + const badgeMetadata = { + // Milestones + 'First Blood': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 1), threshold: 1, earnDifficulty: 'Easy' }, + 'Code Rookie': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 5), threshold: 5, earnDifficulty: 'Easy' }, + 'Code Novice': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 10), threshold: 10, earnDifficulty: 'Easy' }, + 'Challenger': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 25), threshold: 25, earnDifficulty: 'Medium' }, + 'Code Adept': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 50), threshold: 50, earnDifficulty: 'Medium' }, + 'Algorithm Master': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 100), threshold: 100, earnDifficulty: 'Hard' }, + 'Code Sensei': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 150), threshold: 150, earnDifficulty: 'Hard' }, + 'Ascended': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 250), threshold: 250, earnDifficulty: 'Elite' }, + 'The Legend': { category: 'Milestones', progress: Math.min(totalUniqueAccepted, 500), threshold: 500, earnDifficulty: 'Elite' }, + + // Difficulty Mastery + 'Easy Peasy': { category: 'Difficulty', progress: Math.min(diffCount.Easy, 10), threshold: 10, earnDifficulty: 'Easy' }, + 'Warmup Complete': { category: 'Difficulty', progress: Math.min(diffCount.Easy, 25), threshold: 25, earnDifficulty: 'Medium' }, + 'Stepping Up': { category: 'Difficulty', progress: Math.min(diffCount.Medium, 10), threshold: 10, earnDifficulty: 'Medium' }, + 'Midweight Champ': { category: 'Difficulty', progress: Math.min(diffCount.Medium, 25), threshold: 25, earnDifficulty: 'Hard' }, + 'Grandmaster': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 1), threshold: 1, earnDifficulty: 'Hard' }, + 'Abyss Walker': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 10), threshold: 10, earnDifficulty: 'Elite' }, + 'Elite Solver': { category: 'Difficulty', progress: Math.min(diffCount.Hard, 25), threshold: 25, earnDifficulty: 'Elite' }, + + // Streaks + 'Solo Sprint': { category: 'Streaks', progress: Math.min(maxStreak, 1), threshold: 1, earnDifficulty: 'Easy' }, + 'Habit Builder': { category: 'Streaks', progress: Math.min(maxStreak, 3), threshold: 3, earnDifficulty: 'Easy' }, + 'Streak Warrior': { category: 'Streaks', progress: Math.min(maxStreak, 5), threshold: 5, earnDifficulty: 'Medium' }, + 'Consistent Coder': { category: 'Streaks', progress: Math.min(maxStreak, 7), threshold: 7, earnDifficulty: 'Medium' }, + 'Unstoppable': { category: 'Streaks', progress: Math.min(maxStreak, 14), threshold: 14, earnDifficulty: 'Hard' }, + 'Lunar Cycle': { category: 'Streaks', progress: Math.min(maxStreak, 30), threshold: 30, earnDifficulty: 'Elite' }, + 'Night Owl': { category: 'Streaks', progress: nightOwlSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' }, + 'Early Bird': { category: 'Streaks', progress: earlyBirdSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' }, + 'Dawn Coder': { category: 'Streaks', progress: dawnCoderSubmission ? 1 : 0, threshold: 1, earnDifficulty: 'Medium' }, + 'Weekend Warrior': { category: 'Streaks', progress: Math.min(totalWeekendSolves, 5), threshold: 5, earnDifficulty: 'Medium' }, + + // Precision + 'Trial & Error': { category: 'Precision', progress: hasFirstReject ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' }, + 'Flawless': { category: 'Precision', progress: Math.min(flawlessCount, 1), threshold: 1, earnDifficulty: 'Easy' }, + 'Resilient': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 5), threshold: 5, earnDifficulty: 'Medium' }, + 'Sharpshooter': { category: 'Precision', progress: Math.min(flawlessCount, 10), threshold: 10, earnDifficulty: 'Hard' }, + 'Never Surrender': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 10), threshold: 10, earnDifficulty: 'Hard' }, + 'Comeback King': { category: 'Precision', progress: Math.min(maxRejectedBeforeAccept, 3), threshold: 3, earnDifficulty: 'Medium' }, + 'Eagle Eye': { category: 'Precision', progress: Math.min(flawlessCount, 50), threshold: 50, earnDifficulty: 'Elite' }, + + // Languages + 'Polyglot': { category: 'Languages', progress: Math.min(polyglotCount, 3), threshold: 3, earnDifficulty: 'Medium' }, + 'JS Ninja': { category: 'Languages', progress: Math.min((languageCount['javascript'] || languageCount['js'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' }, + 'Pythonista': { category: 'Languages', progress: Math.min((languageCount['python'] || languageCount['py'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' }, + 'Java Juggernaut': { category: 'Languages', progress: Math.min((languageCount['java'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' }, + 'C/C++ Hacker': { category: 'Languages', progress: Math.min((languageCount['cpp'] || languageCount['c'] || languageCount['c++'] || 0), 10), threshold: 10, earnDifficulty: 'Medium' }, + 'Polyglot Pro': { category: 'Languages', progress: Math.min(polyglotCount, 5), threshold: 5, earnDifficulty: 'Hard' }, + + // Special + 'Reviewee': { category: 'Special', progress: hasReviewee ? 1 : 0, threshold: 1, earnDifficulty: 'Easy' }, + 'Perfectionist': { category: 'Special', progress: perfectionistUnlocked ? 1 : 0, threshold: 1, earnDifficulty: 'Elite' }, + 'Speedster': { category: 'Special', progress: speedsterUnlocked ? 1 : 0, threshold: 1, earnDifficulty: 'Hard' }, + 'Marathon': { category: 'Special', progress: Math.min(maxSolvesInOneDay, 5), threshold: 5, earnDifficulty: 'Hard' }, + 'Swift Fingers': { category: 'Special', progress: Math.min(maxSolvesInOneDay, 10),threshold: 10, earnDifficulty: 'Hard' }, }; return badges.map(badge => { const badgeJson = badge.toJSON(); + // Chief badges unlock via awardedBadgeIds + if (badge.isChiefBadge) { + badgeJson.isUnlocked = awardedIds.has(badge._id.toString()); + badgeJson.category = "Chief's Choice"; + badgeJson.progress = badgeJson.isUnlocked ? 1 : 0; + badgeJson.threshold = 1; + badgeJson.earnDifficulty = badge.earnDifficulty || 'Easy'; + return badgeJson; + } + const isUnlocked = !!unlockedMap[badge.name]; + const meta = badgeMetadata[badge.name] || { category: 'Special', progress: 0, threshold: 1, earnDifficulty: 'Medium' }; badgeJson.isUnlocked = isUnlocked; - // We can just use current date for newly unlocked stuff since we don't store unlock dates persistently yet - badgeJson.unlockedAt = isUnlocked ? new Date() : null; + badgeJson.category = meta.category; + badgeJson.progress = meta.progress; + badgeJson.threshold = meta.threshold; + badgeJson.earnDifficulty = badge.earnDifficulty || meta.earnDifficulty; return badgeJson; }); }; -module.exports = { - getAllBadgesForUser -}; +module.exports = { getAllBadgesForUser }; diff --git a/server/src/features/badges/seed.badges.js b/server/src/features/badges/seed.badges.js new file mode 100644 index 0000000..89244c5 --- /dev/null +++ b/server/src/features/badges/seed.badges.js @@ -0,0 +1,93 @@ +/** + * Badge Seeder — Run once to populate the DB with all badges. + * Usage: node server/src/features/badges/seed.badges.js + */ +require('dotenv').config({ path: require('path').resolve(__dirname, '../../../../.env') }); +const mongoose = require('mongoose'); +const Badge = require('./Badge.model'); + +const BADGES = [ + // ── Milestones ────────────────────────────────────────────── + { name: 'First Blood', icon: '🩸', rarity: 'COMMON', color: 'red', earnDifficulty: 'Easy', description: 'Solved your very first challenge.' }, + { name: 'Code Rookie', icon: '🌱', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '5 unique challenges solved.' }, + { name: 'Code Novice', icon: '📗', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '10 unique challenges solved.' }, + { name: 'Challenger', icon: '⚔️', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '25 unique challenges solved.' }, + { name: 'Code Adept', icon: '🧠', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '50 unique challenges solved.' }, + { name: 'Algorithm Master', icon: '🔮', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '100 unique challenges conquered.' }, + { name: 'Code Sensei', icon: '🥷', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '150 unique challenges conquered.' }, + { name: 'Ascended', icon: '🌌', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '250 challenges — you are legendary.' }, + { name: 'The Legend', icon: '👑', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '500 challenges — true mastery achieved.' }, + + // ── Difficulty Mastery ─────────────────────────────────────── + { name: 'Easy Peasy', icon: '🍃', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: '10 Easy problems solved.' }, + { name: 'Warmup Complete', icon: '🏃', rarity: 'COMMON', color: 'green', earnDifficulty: 'Medium', description: '25 Easy problems solved.' }, + { name: 'Stepping Up', icon: '📈', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '10 Medium problems conquered.' }, + { name: 'Midweight Champ', icon: '🥊', rarity: 'RARE', color: 'orange', earnDifficulty: 'Hard', description: '25 Medium problems conquered.' }, + { name: 'Grandmaster', icon: '🏔️', rarity: 'EPIC', color: 'red', earnDifficulty: 'Hard', description: 'First Hard problem solved.' }, + { name: 'Abyss Walker', icon: '🌑', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Elite', description: '10 Hard problems — into the abyss.' }, + { name: 'Elite Solver', icon: '💎', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '25 Hard problems — true elite.' }, + + // ── Streaks & Consistency ──────────────────────────────────── + { name: 'Solo Sprint', icon: '⚡', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'First daily solving streak.' }, + { name: 'Habit Builder', icon: '📅', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: '3-day solving streak maintained.' }, + { name: 'Streak Warrior', icon: '🔥', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: '5-day streak — fire is spreading.' }, + { name: 'Consistent Coder', icon: '🗓️', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '7-day streak — a full week of coding.' }, + { name: 'Unstoppable', icon: '🌊', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '14-day streak — nothing can stop you.' }, + { name: 'Lunar Cycle', icon: '🌕', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '30-day streak — one full lunar cycle.' }, + { name: 'Night Owl', icon: '🦉', rarity: 'RARE', color: 'purple', earnDifficulty: 'Easy', description: 'Solved between 12am – 4am.' }, + { name: 'Dawn Coder', icon: '🌄', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: 'Solved between 4am – 5am.' }, + { name: 'Early Bird', icon: '🐦', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'Solved between 5am – 8am.' }, + { name: 'Weekend Warrior', icon: '🎮', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '5 problems solved on weekends.' }, + + // ── Precision & Accuracy ──────────────────────────────────── + { name: 'Trial & Error', icon: '🔁', rarity: 'COMMON', color: 'green', earnDifficulty: 'Easy', description: 'Your first rejected submission — learn from it.' }, + { name: 'Flawless', icon: '✨', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: 'First-try acceptance on a problem.' }, + { name: 'Comeback King', icon: '🔄', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: 'Accepted after 3+ rejections on the same problem.' }, + { name: 'Resilient', icon: '🛡️', rarity: 'RARE', color: 'orange', earnDifficulty: 'Medium', description: 'Accepted after 5+ rejections on the same problem.' }, + { name: 'Sharpshooter', icon: '🎯', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '10 first-try accepted solutions.' }, + { name: 'Never Surrender', icon: '💪', rarity: 'EPIC', color: 'red', earnDifficulty: 'Hard', description: 'Accepted after 10+ rejections.' }, + { name: 'Eagle Eye', icon: '🦅', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '50 first-try accepted solutions.' }, + + // ── Language Diversity ─────────────────────────────────────── + { name: 'Polyglot', icon: '🌐', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: 'Solved in 3 different languages.' }, + { name: 'JS Ninja', icon: '🟨', rarity: 'RARE', color: 'gold', earnDifficulty: 'Medium', description: '10 problems solved in JavaScript.' }, + { name: 'Pythonista', icon: '🐍', rarity: 'RARE', color: 'blue', earnDifficulty: 'Medium', description: '10 problems solved in Python.' }, + { name: 'Java Juggernaut', icon: '☕', rarity: 'RARE', color: 'red', earnDifficulty: 'Medium', description: '10 problems solved in Java.' }, + { name: 'C/C++ Hacker', icon: '⚙️', rarity: 'RARE', color: 'cyan', earnDifficulty: 'Medium', description: '10 problems solved in C or C++.' }, + { name: 'Polyglot Pro', icon: '🗺️', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: 'Solved in 5 different languages.' }, + + // ── Special ────────────────────────────────────────────────── + { name: 'Reviewee', icon: '📝', rarity: 'COMMON', color: 'blue', earnDifficulty: 'Easy', description: 'Received feedback on a submission.' }, + { name: 'Speedster', icon: '🚀', rarity: 'EPIC', color: 'orange', earnDifficulty: 'Hard', description: '2 different problems solved within 10 minutes.' }, + { name: 'Marathon', icon: '🏅', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Hard', description: '5 problems solved in a single day.' }, + { name: 'Swift Fingers', icon: '⌨️', rarity: 'EPIC', color: 'cyan', earnDifficulty: 'Hard', description: '10 problems solved in a single day.' }, + { name: 'Perfectionist', icon: '💯', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Elite', description: '100% acceptance rate with 10+ accepted.' }, + + // ── Chief's Choice (manually awarded) ──────────────────────── + { name: 'MVP', icon: '⭐', rarity: 'LEGENDARY', color: 'gold', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Most Valuable Player — awarded by Clan Chief.' }, + { name: 'Star Performer', icon: '🌟', rarity: 'EPIC', color: 'purple', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Outstanding performance recognized by the Clan Chief.' }, + { name: 'Rising Star', icon: '🚀', rarity: 'RARE', color: 'blue', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Rapid growth and improvement — awarded by Clan Chief.' }, + { name: 'Team Player', icon: '🤝', rarity: 'RARE', color: 'green', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Excellent collaboration and support within the clan.' }, + { name: 'Honor Roll', icon: '🏆', rarity: 'EPIC', color: 'gold', earnDifficulty: 'Easy', isChiefBadge: true, description: 'Consistent excellence recognized by Clan Chief.' }, +]; + +async function seed() { + await mongoose.connect(process.env.MONGO_URI || process.env.DATABASE_URL); + console.log('Connected to DB'); + + let created = 0, updated = 0; + for (const badge of BADGES) { + const result = await Badge.findOneAndUpdate( + { name: badge.name }, + { $set: badge }, + { upsert: true, new: true, runValidators: true } + ); + if (result.createdAt && result.updatedAt && result.createdAt.getTime() === result.updatedAt.getTime()) created++; + else updated++; + } + + console.log(`✅ Seeded ${BADGES.length} badges — ${created} created, ${updated} updated`); + await mongoose.disconnect(); +} + +seed().catch(err => { console.error(err); process.exit(1); }); diff --git a/server/src/features/dashboard/dashboard.controller.js b/server/src/features/dashboard/dashboard.controller.js index bb80f5f..5606aa9 100644 --- a/server/src/features/dashboard/dashboard.controller.js +++ b/server/src/features/dashboard/dashboard.controller.js @@ -229,9 +229,9 @@ const getUserProfile = async (req, res, next) => { try { let user; if (req.params.userId) { - user = await User.findById(req.params.userId).populate('clan', 'name tag'); + user = await User.findById(req.params.userId).populate('clan', 'name tag').populate('featuredBadge'); } else if (req.params.username) { - user = await User.findOne({ username: req.params.username }).populate('clan', 'name tag'); + user = await User.findOne({ username: req.params.username }).populate('clan', 'name tag').populate('featuredBadge'); } if (!user) { @@ -547,11 +547,30 @@ const getPendingTasks = async (req, res, next) => { } }; +const updateFeaturedBadge = async (req, res, next) => { + try { + const { badgeId } = req.body; + if (!badgeId) { + const user = await User.findByIdAndUpdate(req.user.id, { featuredBadge: null }, { new: true }).populate('featuredBadge'); + return sendSuccess(res, { data: user }); + } + const unlockedBadges = await getAllBadgesForUser(req.user.id); + const hasBadge = unlockedBadges.some(b => b._id.toString() === badgeId.toString()); + if (!hasBadge) { + return res.status(403).json({ success: false, message: 'You have not unlocked this badge.' }); + } + const user = await User.findByIdAndUpdate(req.user.id, { featuredBadge: badgeId }, { new: true }).populate('featuredBadge'); + return sendSuccess(res, { data: user, message: 'Featured badge updated successfully!' }); + } catch (err) { + return next(err); + } +}; + module.exports = { getDashboardSummary, getProfileStats, getUserProfile, + updateFeaturedBadge, getAdminDashboardSummary, getPendingTasks }; - diff --git a/server/src/features/profile/profile.routes.js b/server/src/features/profile/profile.routes.js index faca3a2..624d50b 100644 --- a/server/src/features/profile/profile.routes.js +++ b/server/src/features/profile/profile.routes.js @@ -10,5 +10,7 @@ router.get('/stats', protect, getProfileStats); router.get('/user/:userId', protect, getUserProfile); router.get('/username/:username', getUserProfile); +router.put('/featured-badge', protect, require('../dashboard/dashboard.controller').updateFeaturedBadge); + module.exports = router; diff --git a/server/src/features/submissions/submission.controller.js b/server/src/features/submissions/submission.controller.js index b2c91fe..40f0898 100644 --- a/server/src/features/submissions/submission.controller.js +++ b/server/src/features/submissions/submission.controller.js @@ -424,14 +424,24 @@ const updateSubmissionStatus = async (req, res, next) => { if (userToUpdate) { userToUpdate.points = Math.max(0, (userToUpdate.points || 0) + pointsDiff); userToUpdate.solvedProblems = Math.max(0, (userToUpdate.solvedProblems || 0) + solvedDiff); - - if (userToUpdate.solvedProblems >= 30) { - userToUpdate.codingLevel = 'Advanced'; - } else if (userToUpdate.solvedProblems >= 10) { - userToUpdate.codingLevel = 'Intermediate'; - } else { - userToUpdate.codingLevel = 'Beginner'; + + // Auto-progress coding level unless chief has manually overridden it + if (!userToUpdate.codingLevelOverridden) { + if (userToUpdate.solvedProblems >= 75) { + userToUpdate.codingLevel = 'Advanced'; + } else if (userToUpdate.solvedProblems >= 25) { + userToUpdate.codingLevel = 'Intermediate'; + } else { + userToUpdate.codingLevel = 'Beginner'; + } + } + + // Auto-clear warning when user gets an Accepted submission + if (isNowAccepted && userToUpdate.status === 'Warned') { + userToUpdate.status = 'Active'; + userToUpdate.warningMessage = null; } + await userToUpdate.save(); const XpLog = require('../users/XpLog.model'); diff --git a/server/src/features/users/User.model.js b/server/src/features/users/User.model.js index f34d51c..2cbf890 100644 --- a/server/src/features/users/User.model.js +++ b/server/src/features/users/User.model.js @@ -74,6 +74,10 @@ const userSchema = new mongoose.Schema({ enum: ['Beginner', 'Intermediate', 'Advanced'], default: 'Beginner', }, + codingLevelOverridden: { + type: Boolean, + default: false, + }, preferredLanguage: { type: String, enum: ['javascript', 'python', 'java', 'cpp', 'c'], @@ -129,8 +133,10 @@ const userSchema = new mongoose.Schema({ location: { type: String, default: '' }, github: { type: String, default: '' }, twitter: { type: String, default: '' }, + awardedBadgeIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Badge' }], linkedin: { type: String, default: '' }, website: { type: String, default: '' }, + featuredBadge: { type: mongoose.Schema.Types.ObjectId, ref: 'Badge', default: null }, }); // Partial unique indexes: only documents where the field is a string are diff --git a/server/src/features/users/user.controller.js b/server/src/features/users/user.controller.js index d818789..a443f08 100644 --- a/server/src/features/users/user.controller.js +++ b/server/src/features/users/user.controller.js @@ -90,7 +90,7 @@ const updateUserRole = async (req, res, next) => { // @access Private/Chief/Admin const updateUserLevel = async (req, res, next) => { try { - const { level } = req.body; + const { level, clearOverride } = req.body; if (!['Beginner', 'Intermediate', 'Advanced'].includes(level)) { return res.status(400).json({ success: false, message: 'Invalid level' }); } @@ -104,6 +104,8 @@ const updateUserLevel = async (req, res, next) => { } user.codingLevel = level; + // Mark as manually overridden unless explicitly clearing the override + user.codingLevelOverridden = clearOverride ? false : true; await user.save(); return sendSuccess(res, { data: user, message: 'User level updated' });