Skip to content

Commit 080a952

Browse files
Merge pull request #358 from DevLoversTeam/feat/dashboard-stats-achievements
(SP: 1) [Frontend] Fixes. Dashboard: Profile logic, Notifications
2 parents cdbee69 + e2e2d78 commit 080a952

11 files changed

Lines changed: 289 additions & 109 deletions

File tree

frontend/actions/profile.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 bcrypt from 'bcryptjs';
4+
import { getTranslations } from 'next-intl/server';
5+
import { revalidatePath } from 'next/cache';
6+
7+
import { getUserProfile, updateUser } from '@/db/queries/users';
8+
import { getCurrentUser } from '@/lib/auth';
9+
10+
import { createNotification } from './notifications';
11+
12+
export async function updateName(formData: FormData) {
13+
const session = await getCurrentUser();
14+
if (!session) {
15+
return { error: 'Unauthorized' };
16+
}
17+
18+
const name = formData.get('name') as string;
19+
if (!name || name.trim().length === 0) {
20+
return { error: 'Name is required' };
21+
}
22+
23+
try {
24+
await updateUser(session.id, { name: name.trim() });
25+
26+
// Create notification
27+
const tNotify = await getTranslations('notifications.account');
28+
await createNotification({
29+
userId: session.id,
30+
type: 'SYSTEM',
31+
title: tNotify('nameChanged.title'),
32+
message: tNotify('nameChanged.message', { name: name.trim() }),
33+
});
34+
35+
revalidatePath('/[locale]/dashboard', 'page');
36+
return { success: true };
37+
} catch (error) {
38+
console.error('Failed to update name:', error);
39+
return { error: 'Failed to update name' };
40+
}
41+
}
42+
43+
export async function updatePassword(formData: FormData) {
44+
const session = await getCurrentUser();
45+
if (!session) {
46+
return { error: 'Unauthorized' };
47+
}
48+
49+
const currentPassword = formData.get('currentPassword') as string;
50+
const newPassword = formData.get('newPassword') as string;
51+
52+
if (!currentPassword || !newPassword) {
53+
return { error: 'Both current and new passwords are required' };
54+
}
55+
56+
if (newPassword.length < 8) {
57+
return { error: 'New password must be at least 8 characters long' };
58+
}
59+
60+
try {
61+
// Better to fetch specifically for verification
62+
const { db } = await import('@/db');
63+
const { users } = await import('@/db/schema/users');
64+
const { eq } = await import('drizzle-orm');
65+
66+
const dbUser = await db.query.users.findFirst({
67+
where: eq(users.id, session.id),
68+
});
69+
70+
if (!dbUser || !dbUser.passwordHash) {
71+
return { error: 'Password not set for this account (Social Login?)' };
72+
}
73+
74+
const isValid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
75+
if (!isValid) {
76+
return { error: 'Invalid current password' };
77+
}
78+
79+
const newPasswordHash = await bcrypt.hash(newPassword, 10);
80+
await updateUser(session.id, { passwordHash: newPasswordHash });
81+
82+
// Create notification
83+
const tNotify = await getTranslations('notifications.account');
84+
await createNotification({
85+
userId: session.id,
86+
type: 'SYSTEM',
87+
title: tNotify('passwordChanged.title'),
88+
message: tNotify('passwordChanged.message'),
89+
});
90+
91+
return { success: true };
92+
} catch (error) {
93+
console.error('Failed to update password:', error);
94+
return { error: 'Failed to update password' };
95+
}
96+
}

frontend/components/dashboard/AchievementsSection.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export function AchievementsSection({
2020

2121
const earnedCount = achievements.filter(a => a.earned).length;
2222

23-
const cardStyles = 'dashboard-card';
23+
const cardStyles = 'dashboard-card hover:translate-y-0 hover:shadow-sm';
24+
const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10';
2425

2526
const previewBadges = achievements.slice(0, 6);
2627
const remainingBadges = achievements.slice(6);
@@ -30,7 +31,7 @@ export function AchievementsSection({
3031
<div className="flex w-full flex-row items-center justify-between gap-3 p-4 sm:p-6 md:p-8">
3132
<div className="flex min-w-0 items-center gap-3">
3233
<div
33-
className="shrink-0 rounded-xl bg-gray-100/50 p-3 ring-1 ring-black/5 dark:bg-neutral-800/50 dark:ring-white/10"
34+
className={iconBoxStyles}
3435
aria-hidden="true"
3536
>
3637
<Trophy className="h-5 w-5 text-(--accent-primary) drop-shadow-[0_0_8px_rgba(var(--accent-primary-rgb),0.6)]" />

frontend/components/dashboard/ActivityHeatmapCard.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
5656
relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl
5757
border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md
5858
dark:border-neutral-800 dark:bg-neutral-900/10
59-
p-6 sm:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md
60-
hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30
59+
p-6 sm:p-8
6160
`;
6261

62+
const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10';
63+
6364
const todayStart = useMemo(() => {
6465
const d = new Date();
6566
d.setHours(0, 0, 0, 0);
@@ -258,7 +259,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
258259
<div className="mb-4 sm:mb-6 flex flex-row items-start sm:items-center justify-between gap-3 sm:gap-4 w-full min-w-0">
259260
<div className="flex items-center gap-3 min-w-0">
260261
<div
261-
className="rounded-xl bg-gray-100/50 p-3 ring-1 ring-black/5 dark:bg-neutral-800/50 dark:ring-white/10 shrink-0"
262+
className={iconBoxStyles}
262263
aria-hidden="true"
263264
>
264265
<Activity className="h-5 w-5 text-(--accent-primary) drop-shadow-[0_0_8px_rgba(var(--accent-primary-rgb),0.6)]" />

frontend/components/dashboard/ExplainedTermsCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,16 @@ export function ExplainedTermsCard() {
230230
const hasTerms = terms.length > 0;
231231
const hasHiddenTerms = hiddenTerms.length > 0;
232232

233-
const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10';
233+
const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10 hover:translate-y-0 hover:shadow-sm';
234+
const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10';
234235

235236
return (
236237
<>
237238
<section className={cardStyles} aria-labelledby="explained-terms-heading">
238239
<div>
239240
<div className="mb-6 flex w-full items-center gap-3">
240241
<div
241-
className="shrink-0 rounded-xl bg-gray-100/50 p-3 ring-1 ring-black/5 dark:bg-neutral-800/50 dark:ring-white/10"
242+
className={iconBoxStyles}
242243
aria-hidden="true"
243244
>
244245
<BookOpen className="h-6 w-6 text-(--accent-primary) drop-shadow-[0_0_8px_rgba(var(--accent-primary-rgb),0.6)]" />

frontend/components/dashboard/ProfileCard.tsx

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
import { useTranslations } from 'next-intl';
1414
import { useState } from 'react';
1515

16+
import { toast } from 'sonner';
17+
18+
import { updateName, updatePassword } from '@/actions/profile';
1619
import { UserAvatar } from '@/components/leaderboard/UserAvatar';
1720
import { Link } from '@/i18n/routing';
1821

@@ -57,8 +60,46 @@ export function ProfileCard({
5760
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
5861
};
5962

63+
const handleUpdateName = async (e: React.FormEvent<HTMLFormElement>) => {
64+
e.preventDefault();
65+
setIsSaving(true);
66+
const formData = new FormData(e.currentTarget);
67+
68+
try {
69+
const result = await updateName(formData);
70+
if (!result.success) {
71+
toast.error(result.error || 'Failed to update name');
72+
}
73+
} catch (error) {
74+
toast.error('Something went wrong');
75+
} finally {
76+
setIsSaving(false);
77+
}
78+
};
79+
80+
const handleUpdatePassword = async (e: React.FormEvent<HTMLFormElement>) => {
81+
e.preventDefault();
82+
setIsSaving(true);
83+
const formData = new FormData(e.currentTarget);
84+
85+
try {
86+
const result = await updatePassword(formData);
87+
if (result.success) {
88+
(e.target as HTMLFormElement).reset();
89+
} else {
90+
toast.error(result.error || 'Failed to update password');
91+
}
92+
} catch (error) {
93+
toast.error('Something went wrong');
94+
} finally {
95+
setIsSaving(false);
96+
}
97+
};
98+
6099
const statItemBase =
61-
'flex flex-row items-center gap-2 sm:gap-3 rounded-2xl border border-gray-100 bg-white/50 p-2 sm:p-3 text-left dark:border-white/5 dark:bg-black/20 xl:flex-row-reverse xl:items-center xl:text-right xl:p-3 xl:px-4 transition-all hover:border-(--accent-primary)/40 hover:bg-gray-50 dark:hover:bg-white/5 dark:hover:border-(--accent-primary)/20';
100+
'flex flex-row items-center gap-2 sm:gap-3 rounded-2xl border border-gray-100 bg-white/50 p-2 sm:p-3 text-left dark:border-white/5 dark:bg-black/20 xl:flex-row-reverse xl:items-center xl:text-right xl:p-3 xl:px-4';
101+
102+
const iconBoxStyles = 'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs xl:h-auto xl:w-auto xl:p-2.5 dark:bg-white/5 dark:border-white/10';
62103

63104
return (
64105
<section className={cardStyles} aria-labelledby="profile-heading">
@@ -102,12 +143,8 @@ export function ProfileCard({
102143
</div>
103144
<dl className="grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3 xl:flex xl:w-auto xl:flex-nowrap xl:items-center xl:justify-end xl:gap-2 2xl:gap-3">
104145
{/* Attempts */}
105-
<a
106-
href="#quiz-results"
107-
onClick={scrollTo('quiz-results')}
108-
className={statItemBase}
109-
>
110-
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-purple-100/80 ring-1 ring-black/5 xl:h-auto xl:w-auto xl:p-2.5 dark:bg-purple-500/20 dark:ring-white/10">
146+
<div className={statItemBase}>
147+
<div className={iconBoxStyles}>
111148
<Target className="h-5 w-5 text-purple-600 dark:text-purple-400" />
112149
</div>
113150
<div className="flex w-full flex-col items-start overflow-hidden xl:items-end">
@@ -118,11 +155,11 @@ export function ProfileCard({
118155
{totalAttempts}
119156
</dd>
120157
</div>
121-
</a>
158+
</div>
122159

123160
{/* Points */}
124-
<a href="#stats" onClick={scrollTo('stats')} className={statItemBase}>
125-
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100/80 ring-1 ring-black/5 xl:h-auto xl:w-auto xl:p-2.5 dark:bg-amber-500/20 dark:ring-white/10">
161+
<div className={statItemBase}>
162+
<div className={iconBoxStyles}>
126163
<Trophy className="h-5 w-5 text-amber-600 dark:text-amber-400" />
127164
</div>
128165
<div className="flex w-full flex-col items-start overflow-hidden xl:items-end">
@@ -133,11 +170,11 @@ export function ProfileCard({
133170
{user.points}
134171
</dd>
135172
</div>
136-
</a>
173+
</div>
137174

138175
{/* Global rank */}
139-
<Link href="/leaderboard" className={statItemBase}>
140-
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-teal-100/80 ring-1 ring-black/5 xl:h-auto xl:w-auto xl:p-2.5 dark:bg-teal-500/20 dark:ring-white/10">
176+
<div className={statItemBase}>
177+
<div className={iconBoxStyles}>
141178
<Globe className="h-5 w-5 text-teal-600 dark:text-teal-400" />
142179
</div>
143180
<div className="flex w-full flex-col items-start overflow-hidden xl:items-end">
@@ -148,11 +185,11 @@ export function ProfileCard({
148185
{globalRank ? `#${globalRank}` : '—'}
149186
</dd>
150187
</div>
151-
</Link>
188+
</div>
152189

153190
{/* Joined */}
154191
<div className="flex flex-row items-center gap-2 rounded-2xl border border-gray-100 bg-white/50 p-2 text-left sm:gap-3 sm:p-3 xl:flex-row-reverse xl:items-center xl:p-3 xl:px-4 xl:text-right dark:border-white/5 dark:bg-black/20">
155-
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-100/80 ring-1 ring-black/5 xl:h-auto xl:w-auto xl:p-2.5 dark:bg-blue-500/20 dark:ring-white/10">
192+
<div className={iconBoxStyles}>
156193
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
157194
</div>
158195
<div className="flex w-full flex-col items-start overflow-hidden xl:items-end">
@@ -204,20 +241,14 @@ export function ProfileCard({
204241
<h3 className="mb-4 text-sm font-semibold tracking-wide text-gray-900 uppercase dark:text-white">
205242
{t('changeName')}
206243
</h3>
207-
<form
208-
onSubmit={e => {
209-
e.preventDefault();
210-
setIsSaving(true);
211-
setTimeout(() => setIsSaving(false), 1000);
212-
}}
213-
className="flex flex-col gap-4 sm:flex-row sm:items-end"
214-
>
244+
<form onSubmit={handleUpdateName} className="flex flex-col gap-4 sm:flex-row sm:items-end">
215245
<div className="flex-1">
216246
<label htmlFor="name-input" className="sr-only">
217247
{t('changeName')}
218248
</label>
219249
<input
220250
id="name-input"
251+
name="name"
221252
type="text"
222253
defaultValue={user.name || ''}
223254
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm transition-all outline-none placeholder:text-gray-400 focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary) dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
@@ -240,20 +271,11 @@ export function ProfileCard({
240271
<h3 className="mb-4 text-sm font-semibold tracking-wide text-gray-900 uppercase dark:text-white">
241272
{t('changePassword')}
242273
</h3>
243-
<form
244-
onSubmit={e => {
245-
e.preventDefault();
246-
if (e.currentTarget.checkValidity()) {
247-
setIsSaving(true);
248-
setTimeout(() => setIsSaving(false), 1000);
249-
e.currentTarget.reset();
250-
}
251-
}}
252-
className="flex flex-col gap-4"
253-
>
274+
<form onSubmit={handleUpdatePassword} className="flex flex-col gap-4">
254275
<div>
255276
<input
256277
type="password"
278+
name="currentPassword"
257279
placeholder={t('currentPassword')}
258280
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm transition-all outline-none placeholder:text-gray-400 focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary) dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"
259281
required
@@ -262,6 +284,7 @@ export function ProfileCard({
262284
<div>
263285
<input
264286
type="password"
287+
name="newPassword"
265288
placeholder={t('newPassword')}
266289
minLength={8}
267290
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm transition-all outline-none placeholder:text-gray-400 focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary) dark:border-neutral-800 dark:bg-neutral-900 dark:text-white"

frontend/components/dashboard/QuizResultsSection.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps
1616
const t = useTranslations('dashboard.quizResults');
1717

1818
const cardStyles =
19-
'relative z-10 flex flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white/10 p-6 sm:p-8 lg:p-10 shadow-sm backdrop-blur-md transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:border-(--accent-primary)/30 dark:border-neutral-800 dark:bg-neutral-900/10 dark:hover:border-(--accent-primary)/30';
19+
'relative z-10 flex flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white/10 p-6 sm:p-8 lg:p-10 shadow-sm backdrop-blur-md dark:border-neutral-800 dark:bg-neutral-900/10';
20+
21+
const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10';
2022

2123
const primaryBtnStyles =
2224
'group relative inline-flex items-center justify-center rounded-full px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white bg-(--accent-primary) hover:bg-(--accent-hover) transition-all hover:scale-105';
@@ -48,7 +50,7 @@ export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps
4850
<section className={cardStyles}>
4951
<div className="mb-6 flex items-center gap-3">
5052
<div
51-
className="rounded-xl bg-gray-100/50 p-3 ring-1 ring-black/5 dark:bg-neutral-800/50 dark:ring-white/10"
53+
className={iconBoxStyles}
5254
aria-hidden="true"
5355
>
5456
<ClipboardList className="h-5 w-5 text-(--accent-primary) drop-shadow-[0_0_8px_rgba(var(--accent-primary-rgb),0.6)]" />

frontend/components/dashboard/StatsCard.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,12 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) {
2626
const tProfile = useTranslations('dashboard.profile');
2727
const hasActivity = stats && stats.totalAttempts > 0;
2828

29-
const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8';
29+
const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8 hover:translate-y-0 hover:shadow-sm';
3030

31-
const primaryBtnStyles = `
32-
group relative inline-flex items-center justify-center rounded-full
33-
px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white
34-
bg-(--accent-primary) hover:bg-(--accent-hover)
35-
transition-all hover:scale-105
36-
`;
31+
const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10';
32+
33+
const primaryBtnStyles =
34+
'group relative inline-flex items-center justify-center rounded-full px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white bg-(--accent-primary) hover:bg-(--accent-hover) transition-all hover:scale-105';
3735

3836
// Calculate chart metrics
3937
const excellent = attempts.filter(a => Number(a.percentage) >= 100).length;
@@ -60,7 +58,7 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) {
6058
<section className={cardStyles} aria-labelledby="stats-heading">
6159
<div className="mb-6 flex w-full items-center gap-3">
6260
<div
63-
className="rounded-xl bg-gray-100/50 p-3 ring-1 ring-black/5 dark:bg-neutral-800/50 dark:ring-white/10"
61+
className={iconBoxStyles}
6462
aria-hidden="true"
6563
>
6664
<Target className="h-5 w-5 text-(--accent-primary) drop-shadow-[0_0_8px_rgba(var(--accent-primary-rgb),0.6)]" />

0 commit comments

Comments
 (0)