Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -81,6 +82,7 @@ function App() {
<Route path="/missions" element={<Missions />} />
<Route path="/pending-tasks" element={<PendingTasks />} />
<Route path="/settings" element={<Settings />} />
<Route path="/badges" element={<Badges />} />

<Route path="/chief-panel" element={<ClanChiefRoute><ClanChiefPanel /></ClanChiefRoute>} />

Expand Down
75 changes: 75 additions & 0 deletions client/src/components/BadgeMedal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative flex flex-col items-center justify-center w-24 h-28 group">
{/* Glossy Overlay for realistic 3D feel */}
<div className={`absolute top-2 w-20 h-20 rounded-full z-20 pointer-events-none ${isUnlocked ? 'bg-gradient-to-br from-white/40 to-transparent' : 'bg-gradient-to-br from-white/10 to-transparent'}`} style={{ clipPath: 'ellipse(70% 40% at 50% 20%)' }}></div>

{/* Ribbon (Optional: a small tab at the top) */}
<div className={`absolute top-0 w-8 h-6 ${ribbonColor} rounded-t-sm z-0 before:content-[''] before:absolute before:w-full before:h-2 before:bg-black/20 before:bottom-0`}></div>

{/* The 3D Coin/Medal Shape */}
<div className={`relative z-10 w-20 h-20 rounded-full bg-gradient-to-br ${outerGradient} p-1.5 shadow-xl ${shadowColor} flex items-center justify-center transition-transform duration-500 group-hover:rotate-y-12 group-hover:scale-105`}
style={{ boxShadow: isUnlocked ? '0 10px 25px -5px var(--tw-shadow-color), inset 0 -4px 6px rgba(0,0,0,0.4), inset 0 4px 6px rgba(255,255,255,0.4)' : 'inset 0 -4px 6px rgba(0,0,0,0.6)' }}>

{/* Inner engraved section */}
<div className={`w-full h-full rounded-full bg-gradient-to-br ${innerGradient} flex items-center justify-center`}
style={{ boxShadow: 'inset 0 4px 8px rgba(0,0,0,0.8)' }}>

{/* The Icon */}
<div className={`text-4xl filter drop-shadow-md transition-all duration-300 ${!isUnlocked && 'opacity-40 grayscale'} ${isUnlocked && 'group-hover:scale-110'}`}>
{icon}
</div>
</div>
</div>

{/* 3D Base/Stand underneath */}
<div className="w-12 h-2 bg-black/40 rounded-[100%] blur-sm absolute bottom-0"></div>
</div>
);
};

export default BadgeMedal;
97 changes: 95 additions & 2 deletions client/src/components/NotificationListener.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) => (
<div
className="flex items-center gap-3 cursor-pointer"
onClick={() => {
toast.dismiss(t.id);
navigate('/badges');
}}
>
<div className="text-4xl">{badge.icon}</div>
<div>
<p className="font-bold text-sm text-accent uppercase tracking-wider">Badge Unlocked!</p>
<p className="text-sm font-bold text-primary">{badge.name}</p>
</div>
</div>
), {
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;
Expand Down
9 changes: 7 additions & 2 deletions client/src/components/ProfileSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -326,12 +330,13 @@ const ProfileSidebar = ({ user, summary, profile, badges }) => {
{/* Divider */}
<div className="h-px bg-black/[0.08] dark:bg-white/[0.08]" />

{/* Stats 2×2 grid */}
<div className="grid grid-cols-2 gap-2">
{/* Stats grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<StatPill icon={FiTarget} value={`${solved}/${total}`} label="Solved" color="text-green-400" />
<StatPill icon={FiStar} value={rank !== "—" ? `#${rank}` : "—"} label="Global Rank" color="text-yellow-400" />
<StatPill icon={FiZap} value={`${streak}d`} label="Streak" color="text-accent" sublabel={maxStreak > 0 ? `best ${maxStreak}d` : undefined} />
<StatPill icon={FiClock} value={pending} label="Pending" color="text-orange-400" />
<StatPill icon={FiAward} value={clanBadgeCount} label="Clan Badges" color="text-amber-500" />
</div>

{/* Divider */}
Expand Down
Loading
Loading