Skip to content

Commit c49cf46

Browse files
feat: improve dashboard, stats, and header UI
1 parent 9934bf3 commit c49cf46

31 files changed

Lines changed: 4543 additions & 296 deletions

frontend/actions/notifications.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use server';
2+
3+
import { desc, eq, and } from 'drizzle-orm';
4+
import { revalidatePath } from 'next/cache';
5+
6+
import { db } from '@/db';
7+
import { notifications } from '@/db/schema/notifications';
8+
9+
import { getCurrentUser } from '@/lib/auth';
10+
11+
export async function getNotifications() {
12+
const session = await getCurrentUser();
13+
if (!session) return [];
14+
15+
try {
16+
const data = await db.query.notifications.findMany({
17+
where: eq(notifications.userId, session.id),
18+
orderBy: [desc(notifications.createdAt)],
19+
limit: 20,
20+
});
21+
return data;
22+
} catch (error) {
23+
console.error('Failed to fetch notifications:', error);
24+
return [];
25+
}
26+
}
27+
28+
export async function markAsRead(notificationId: string) {
29+
const session = await getCurrentUser();
30+
if (!session || !notificationId) return { success: false };
31+
32+
try {
33+
await db
34+
.update(notifications)
35+
.set({ isRead: true })
36+
.where(
37+
and(
38+
eq(notifications.id, notificationId),
39+
eq(notifications.userId, session.id)
40+
)
41+
);
42+
43+
revalidatePath('/', 'layout');
44+
return { success: true };
45+
} catch (error) {
46+
console.error('Failed to mark notification as read:', error);
47+
return { success: false };
48+
}
49+
}
50+
51+
export async function markAllAsRead() {
52+
const session = await getCurrentUser();
53+
if (!session) return { success: false };
54+
55+
try {
56+
await db
57+
.update(notifications)
58+
.set({ isRead: true })
59+
.where(and(eq(notifications.userId, session.id), eq(notifications.isRead, false)));
60+
61+
revalidatePath('/', 'layout');
62+
return { success: true };
63+
} catch (error) {
64+
console.error('Failed to mark all notifications as read:', error);
65+
return { success: false };
66+
}
67+
}
68+
69+
export async function createNotification(data: {
70+
userId: string;
71+
type: string;
72+
title: string;
73+
message: string;
74+
metadata?: any;
75+
}) {
76+
if (!data.userId) return null;
77+
78+
try {
79+
const [result] = await db
80+
.insert(notifications)
81+
.values({
82+
userId: data.userId,
83+
type: data.type,
84+
title: data.title,
85+
message: data.message,
86+
metadata: data.metadata || null,
87+
})
88+
.returning();
89+
90+
revalidatePath('/', 'layout');
91+
return result;
92+
} catch (error) {
93+
console.error('Failed to create notification:', error);
94+
return null;
95+
}
96+
}

frontend/actions/quiz.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
quizQuestions,
1212
} from '@/db/schema/quiz';
1313
import { getCurrentUser } from '@/lib/auth';
14+
import { ACHIEVEMENTS, computeAchievements } from '@/lib/achievements';
15+
import { getUserStatsForAchievements } from '@/lib/user-stats';
16+
import { createNotification } from './notifications';
1417

1518
export interface UserAnswer {
1619
questionId: string;
@@ -88,6 +91,16 @@ export async function submitQuizAttempt(
8891
return { success: false, error: 'User mismatch' };
8992
}
9093

94+
// Capture user achievements state BEFORE saving this attempt
95+
const statsBefore = await getUserStatsForAchievements(session.id);
96+
const earnedBefore = new Set(
97+
statsBefore
98+
? computeAchievements(statsBefore)
99+
.filter(a => a.earned)
100+
.map(a => a.id)
101+
: []
102+
);
103+
91104
if (!quizId || !Array.isArray(answers) || answers.length === 0) {
92105
return {
93106
success: false,
@@ -213,6 +226,25 @@ export async function submitQuizAttempt(
213226
integrityScore,
214227
});
215228

229+
// Capture user achievements state AFTER saving this attempt
230+
const statsAfter = await getUserStatsForAchievements(session.id);
231+
if (statsAfter) {
232+
const earnedAfter = computeAchievements(statsAfter).filter(a => a.earned);
233+
const newlyEarned = earnedAfter.filter(a => !earnedBefore.has(a.id));
234+
235+
// Trigger notifications for any newly earned achievements
236+
for (const achievement of newlyEarned) {
237+
// Find full object to get the fancy translated string (if needed) or just generic name
238+
await createNotification({
239+
userId: session.id,
240+
type: 'ACHIEVEMENT',
241+
title: 'Achievement Unlocked!',
242+
message: `You just earned the ${achievement.id} badge!`,
243+
metadata: { badgeId: achievement.id, icon: achievement.icon },
244+
});
245+
}
246+
}
247+
216248
return {
217249
success: true,
218250
attemptId: attempt.id,

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 22 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground
1414
import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quizzes/quiz';
1515
import { getUserProfile, getUserGlobalRank } from '@/db/queries/users';
1616
import { redirect } from '@/i18n/routing';
17-
import { getSponsors, getAllSponsors } from '@/lib/about/github-sponsors';
1817
import { getCurrentUser } from '@/lib/auth';
1918
import { computeAchievements } from '@/lib/achievements';
20-
import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars';
19+
import { getUserStatsForAchievements } from '@/lib/user-stats';
2120

2221
export async function generateMetadata({
2322
params,
@@ -53,59 +52,16 @@ export default async function DashboardPage({
5352

5453
const t = await getTranslations('dashboard');
5554

56-
// Active sponsors — used for the sponsor badge / button display in the UI
57-
const sponsors = await getSponsors();
58-
// All-time sponsors (active + past) — used for the Supporter achievement check
59-
const allSponsors = await getAllSponsors();
60-
61-
const userEmail = user.email.toLowerCase();
62-
const userName = (user.name ?? '').toLowerCase();
63-
const userImage = user.image ?? '';
64-
65-
function findSponsor(list: typeof sponsors) {
66-
return list.find(s => {
67-
if (s.email && s.email.toLowerCase() === userEmail) return true;
68-
if (userName && s.login && s.login.toLowerCase() === userName) return true;
69-
if (userName && s.name && s.name.toLowerCase() === userName) return true;
70-
if (
71-
userImage &&
72-
s.avatarUrl &&
73-
s.avatarUrl.trim().length > 0 &&
74-
userImage.includes(s.avatarUrl.split('?')[0])
75-
) return true;
76-
return false;
77-
});
78-
}
79-
80-
const matchedSponsor = findSponsor(sponsors); // active — for UI display
81-
const everSponsor = findSponsor(allSponsors); // all-time — for achievements
82-
83-
// Determine the GitHub login to check against the stargazers list.
84-
// Priority:
85-
// 1. Matched sponsor login (most reliable — org PAT already resolved it)
86-
// 2. For GitHub-OAuth users: resolve login from numeric providerId
87-
// 3. user.name as last resort (may be a display name, not a login!)
88-
let githubLogin = matchedSponsor?.login || '';
89-
if (!githubLogin && user.provider === 'github' && user.providerId) {
90-
githubLogin = (await resolveGitHubLogin(user.providerId)) ?? user.name ?? '';
91-
} else if (!githubLogin) {
92-
githubLogin = user.name ?? '';
93-
}
94-
95-
const hasStarredRepo = githubLogin
96-
? await checkHasStarredRepo(githubLogin)
97-
: false;
98-
9955
const attempts = await getUserQuizStats(session.id);
10056
const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
10157

10258
const totalAttempts = attempts.length;
10359

10460
const averageScore =
105-
totalAttempts > 0
61+
lastAttempts.length > 0
10662
? Math.round(
107-
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
108-
totalAttempts
63+
lastAttempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
64+
lastAttempts.length
10965
)
11066
: 0;
11167

@@ -184,33 +140,16 @@ export default async function DashboardPage({
184140
trendPercentage,
185141
};
186142

187-
const perfectScores = attempts.filter((a) => Number(a.percentage) === 100).length;
188-
const highScores = attempts.filter((a) => Number(a.percentage) >= 90).length;
189-
const uniqueQuizzes = lastAttempts.length;
190-
191-
// Night Owl: any attempt completed between 00:00 and 05:00 local time
192-
const hasNightOwl = attempts.some((a) => {
193-
if (!a.completedAt) return false;
194-
const hour = new Date(a.completedAt).getHours();
195-
return hour >= 0 && hour < 5;
196-
});
143+
const userStats = await getUserStatsForAchievements(session.id);
144+
const achievements = userStats ? computeAchievements(userStats) : [];
197145

198-
const achievements = computeAchievements({
199-
totalAttempts,
200-
averageScore,
201-
perfectScores,
202-
highScores,
203-
isSponsor: !!everSponsor,
204-
uniqueQuizzes,
205-
totalPoints: user.points,
206-
topLeaderboard: false,
207-
hasStarredRepo,
208-
sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count
209-
hasNightOwl,
210-
});
146+
const isMatchedSponsor = userStats ? userStats.sponsorCount > 0 : false;
211147

212148
const outlineBtnStyles =
213-
'inline-flex items-center justify-center rounded-full border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm px-6 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 transition-colors hover:bg-white hover:text-(--accent-primary) dark:hover:bg-neutral-800 dark:hover:text-(--accent-primary)';
149+
'inline-flex items-center justify-center rounded-full border border-gray-200/50 bg-white/10 px-6 py-2.5 text-sm font-semibold tracking-wide text-gray-700 backdrop-blur-md transition-all hover:-translate-y-0.5 hover:bg-white/20 hover:shadow-md hover:border-gray-300 dark:border-white/10 dark:bg-neutral-900/40 dark:text-gray-200 dark:hover:bg-neutral-800/80 dark:hover:border-white/20';
150+
151+
const sponsorBtnStyles =
152+
'group relative inline-flex items-center justify-center gap-2 rounded-full border border-(--accent-primary)/30 bg-(--accent-primary)/10 px-6 py-2.5 text-sm font-semibold tracking-wide text-(--accent-primary) backdrop-blur-md transition-all hover:-translate-y-0.5 hover:bg-(--accent-primary)/20 hover:shadow-[0_4px_12px_rgba(var(--accent-primary-rgb),0.2)] hover:border-(--accent-primary)/50 dark:border-(--accent-primary)/20 dark:bg-(--accent-primary)/5 dark:hover:bg-(--accent-primary)/20 dark:hover:border-(--accent-primary)/40 dark:hover:shadow-[0_4px_15px_rgba(var(--accent-primary-rgb),0.3)] overflow-hidden';
214153

215154
return (
216155
<div className="min-h-screen">
@@ -234,17 +173,22 @@ export default async function DashboardPage({
234173
href="#feedback"
235174
className={`group flex items-center gap-2 ${outlineBtnStyles}`}
236175
>
237-
<MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5" />
176+
<MessageSquare className="h-4 w-4 transition-transform group-hover:-translate-y-0.5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-white" />
238177
{t('supportLink')}
239178
</a>
240179
<a
241180
href="https://github.com/sponsors/DevLoversTeam"
242181
target="_blank"
243182
rel="noopener noreferrer"
244-
className="group inline-flex items-center justify-center gap-2 rounded-full border border-(--accent-primary) bg-(--accent-primary)/10 px-6 py-2 text-sm font-medium text-(--accent-primary) transition-colors hover:bg-(--accent-primary) hover:text-white dark:border-(--accent-primary)/50 dark:bg-(--accent-primary)/10 dark:text-(--accent-primary) dark:hover:bg-(--accent-primary) dark:hover:text-white"
183+
className={sponsorBtnStyles}
245184
>
246-
<Heart className="h-4 w-4 transition-transform group-hover:scale-110" />
247-
{!!matchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')}
185+
{/* Subtle gradient glow background effect */}
186+
<div className="absolute inset-0 z-0 bg-linear-to-r from-transparent via-(--accent-primary)/10 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
187+
188+
<span className="relative z-10 flex items-center gap-2">
189+
<Heart className="h-4 w-4 transition-transform group-hover:scale-110 group-hover:fill-(--accent-primary)/20" />
190+
{isMatchedSponsor ? t('profile.supportAgain') : t('profile.becomeSponsor')}
191+
</span>
248192
</a>
249193
</div>
250194
</header>
@@ -253,12 +197,12 @@ export default async function DashboardPage({
253197
<ProfileCard
254198
user={userForDisplay}
255199
locale={locale}
256-
isSponsor={!!matchedSponsor}
200+
isSponsor={isMatchedSponsor}
257201
totalAttempts={totalAttempts}
258202
globalRank={globalRank}
259203
/>
260204
<div className="grid gap-8 lg:grid-cols-2">
261-
<StatsCard stats={stats} attempts={attempts} />
205+
<StatsCard stats={stats} attempts={lastAttempts} />
262206
<ActivityHeatmapCard attempts={attempts} locale={locale} currentStreak={currentStreak} />
263207
</div>
264208
</div>

frontend/components/auth/logoutButton.tsx

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,12 @@ export function LogoutButton({ iconOnly = false }: LogoutButtonProps) {
3737
<button
3838
type="button"
3939
onClick={handleLogout}
40-
className="group bg-secondary text-secondary-foreground relative inline-flex w-fit items-center gap-2 overflow-hidden rounded-lg px-4 py-2 text-sm font-medium transition-all duration-500 hover:text-white active:text-white"
40+
className="group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:bg-(--accent-primary)/10 hover:text-(--accent-primary) active:bg-(--accent-primary)/20"
4141
>
42-
<span
43-
className="absolute inset-0 opacity-0 transition-opacity duration-500 ease-out group-hover:opacity-100 group-active:opacity-100"
44-
style={{
45-
background:
46-
'linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-hover) 100%)',
47-
}}
48-
aria-hidden="true"
49-
/>
50-
51-
<span
52-
className="absolute inset-0 translate-x-[-100%] transition-transform duration-1000 ease-in-out group-hover:translate-x-[100%] group-active:translate-x-[100%]"
53-
style={{
54-
background:
55-
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
56-
transform: 'skewX(-20deg)',
57-
}}
58-
aria-hidden="true"
59-
/>
60-
61-
<span className="relative z-10 flex items-center gap-2">
62-
<LogOut className="h-4 w-4 transition-transform duration-300 group-hover:scale-110 group-active:scale-110" />
63-
{t('logout')}
64-
</span>
42+
<div className="flex items-center gap-2">
43+
<LogOut className="h-4 w-4" />
44+
<span>{t('logout')}</span>
45+
</div>
6546
</button>
6647
);
6748
}

frontend/components/dashboard/AchievementBadge.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ import {
2020
Moon,
2121
Shield,
2222
Waves,
23+
Meteor,
24+
Sparkle,
25+
GraduationCap,
26+
Atom,
27+
Sun,
28+
Anchor,
2329
} from '@phosphor-icons/react';
2430
import { useTranslations } from 'next-intl';
2531
import { useState, useEffect } from 'react';
@@ -45,6 +51,12 @@ const ICON_MAP: Record<AchievementIconName, React.ElementType> = {
4551
Moon,
4652
Shield,
4753
Waves,
54+
Meteor,
55+
Sparkle,
56+
GraduationCap,
57+
Atom,
58+
Sun,
59+
Anchor,
4860
};
4961

5062
interface AchievementBadgeProps {

frontend/components/dashboard/ActivityHeatmapCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
376376
<div className="w-full min-w-max pb-1">
377377
<svg width={svgWidth} height={svgHeight} viewBox={`0 0 ${svgWidth} ${svgHeight}`} className="block">
378378
<defs>
379-
<filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
379+
<filter id="neonGlow" x="-200%" y="-200%" width="500%" height="500%">
380380
<feGaussianBlur stdDeviation="3" result="blur" />
381381
<feMerge>
382382
<feMergeNode in="blur" />

frontend/components/dashboard/FeedbackForm.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,13 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) {
6767
`;
6868

6969
const inputStyles =
70-
'w-full rounded-xl border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-800/50 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary)';
70+
'w-full rounded-xl border border-gray-200/50 bg-white/20 dark:border-white/10 dark:bg-neutral-800/40 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none backdrop-blur-md transition-all hover:bg-white/40 dark:hover:bg-neutral-800/60 focus:border-(--accent-primary)/50 focus:bg-white/60 dark:focus:bg-neutral-800/80 focus:ring-1 focus:ring-(--accent-primary)';
7171

7272
const primaryBtnStyles = `
7373
group relative inline-flex items-center justify-center gap-2 rounded-full
7474
px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white
75-
bg-(--accent-primary) hover:bg-(--accent-hover)
75+
bg-(--accent-primary) hover:bg-(--accent-hover) hover:shadow-[0_4px_12px_rgba(var(--accent-primary-rgb),0.3)]
76+
transition-all hover:-translate-y-0.5
7677
disabled:opacity-75
7778
`;
7879

0 commit comments

Comments
 (0)