Skip to content

Commit 16b2297

Browse files
Merge pull request #359 from DevLoversTeam/develop
Release v1.0.2
2 parents 419c18b + b3e4e56 commit 16b2297

210 files changed

Lines changed: 35053 additions & 13882 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/telegram-pr-opened.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
runs-on: ubuntu-latest
1010
steps:
1111
- name: Send Telegram message
12-
uses: appleboy/telegram-action@v1.0.1
12+
uses: appleboy/telegram-action@221e6b684967abe813051ee4a37dd61770a83ad3
1313
with:
1414
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
1515
to: ${{ secrets.TELEGRAM_CHAT_ID }}

CHANGELOG.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
624624

625625
- Improved Redis cache reliability for Q&A
626626
- Extended automated tests for caching and payment flows
627+
628+
## [1.0.2] - 2026-02-23
629+
630+
### Added
631+
632+
- Dashboard engagement system:
633+
- Activity Heatmap with daily streak tracking and tooltips
634+
- Expanded Achievements system (24 badges across multiple categories)
635+
- Score distribution visualization and improved analytics
636+
- Profile & account management:
637+
- User name update with validation
638+
- Secure password change (bcrypt hashing)
639+
- Localized system notifications with Notification Center
640+
- Admin platform expansion:
641+
- Unified admin panel under `/admin` with collapsible sidebar
642+
- Quiz Admin: full create workflow via JSON upload
643+
- Draft → Ready → Activate publishing flow with translation validation
644+
- Edit existing quizzes with locale-aware editor and Redis invalidation
645+
- Content & learning:
646+
- Added SQL, PostgreSQL, MongoDB category to Q&A
647+
- User engagement:
648+
- In-app feedback form with multiple attachments
649+
- Sponsor recognition and GitHub star achievements
650+
- Shop UX:
651+
- “My Orders” summary card on Cart page
652+
653+
### Changed
654+
655+
- Dashboard UX:
656+
- Unified glassmorphism visual language across cards
657+
- Clickable stats sections with smooth navigation
658+
- Leaderboard:
659+
- Top 15 users with contextual ranking around current user
660+
- Improved sponsor styling and visual consistency
661+
- Header & navigation:
662+
- Unified dropdown styles (Notifications / Profile / Language)
663+
- Dependencies:
664+
- Upgraded Next.js to **16.1.6**
665+
- Codebase:
666+
- Large-scale formatting and structural refactoring
667+
668+
### Fixed
669+
670+
- Dashboard profile logic and statistics inconsistencies
671+
- Notification rendering and layout issues
672+
- Quiz editor Save button now disabled when no changes made
673+
- Admin quiz cache invalidation when status changes
674+
- Improved mobile menu scroll locking
675+
- Fixed Vercel build issues and migration conflicts
676+
677+
### Performance & Reliability
678+
679+
- Improved Redis cache invalidation for quizzes
680+
- Optimized dashboard data fetching
681+
- Safer database migration handling
682+
683+
### Security
684+
685+
- Stronger password validation and confirmation flow
686+
- Improved server-side validation and error reporting

frontend/.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ JANITOR_URL=
6161
# --- Quiz
6262
QUIZ_ENCRYPTION_KEY=
6363

64+
# --- Web3Forms (feedback form)
65+
NEXT_PUBLIC_WEB3FORMS_KEY=
66+
67+
GITHUB_SPONSORS_TOKEN=
68+
6469
# --- Telegram
6570
TELEGRAM_BOT_TOKEN=
6671
TELEGRAM_CHAT_ID=
@@ -102,4 +107,6 @@ TRUST_FORWARDED_HEADERS=0
102107
# emergency switch
103108
RATE_LIMIT_DISABLED=0
104109

105-
GROQ_API_KEY=
110+
GROQ_API_KEY=
111+
112+
NEXT_PUBLIC_WEB3FORMS_KEY=

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/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/actions/quiz.ts

Lines changed: 34 additions & 2 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;
@@ -49,7 +52,6 @@ function calculateIntegrityScore(violations: ViolationEvent[]): number {
4952
return Math.max(0, 100 - penalty);
5053
}
5154

52-
5355
async function getQuizQuestionIds(quizId: string): Promise<string[]> {
5456
const rows = await db
5557
.select({ id: quizQuestions.id })
@@ -88,6 +90,16 @@ export async function submitQuizAttempt(
8890
return { success: false, error: 'User mismatch' };
8991
}
9092

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

228+
// Capture user achievements state AFTER saving this attempt
229+
const statsAfter = await getUserStatsForAchievements(session.id);
230+
if (statsAfter) {
231+
const earnedAfter = computeAchievements(statsAfter).filter(a => a.earned);
232+
const newlyEarned = earnedAfter.filter(a => !earnedBefore.has(a.id));
233+
234+
// Trigger notifications for any newly earned achievements
235+
for (const achievement of newlyEarned) {
236+
// Find full object to get the fancy translated string (if needed) or just generic name
237+
await createNotification({
238+
userId: session.id,
239+
type: 'ACHIEVEMENT',
240+
title: 'Achievement Unlocked!',
241+
message: `You just earned the ${achievement.id} badge!`,
242+
metadata: { badgeId: achievement.id, icon: achievement.icon },
243+
});
244+
}
245+
}
246+
216247
return {
217248
success: true,
218249
attemptId: attempt.id,
@@ -238,7 +269,8 @@ export async function initializeQuizCache(
238269
const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
239270
await import('@/lib/quiz/quiz-answers-redis');
240271

241-
const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier');
272+
const { resolveRequestIdentifier } =
273+
await import('@/lib/quiz/resolve-identifier');
242274
const { headers } = await import('next/headers');
243275
const headersList = await headers();
244276
const identifier = resolveRequestIdentifier(headersList);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AchievementsSection } from '@/components/dashboard/AchievementsSection';
2+
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
3+
import { computeAchievements } from '@/lib/achievements';
4+
5+
export default function AchievementsDemoPage() {
6+
// Mix of earned and unearned for a realistic preview
7+
const achievements = computeAchievements({
8+
totalAttempts: 4,
9+
averageScore: 78,
10+
perfectScores: 1,
11+
highScores: 2,
12+
isSponsor: false,
13+
uniqueQuizzes: 4,
14+
totalPoints: 80,
15+
topLeaderboard: false,
16+
hasStarredRepo: true, // demo: show star_gazer as earned
17+
sponsorCount: 0,
18+
hasNightOwl: false,
19+
});
20+
21+
return (
22+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-16 dark:bg-transparent">
23+
<main className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
24+
<div className="mb-10 text-center">
25+
<h1 className="text-4xl font-black tracking-tight text-gray-900 dark:text-white">
26+
🏅 Achievements Preview
27+
</h1>
28+
<p className="mt-2 text-gray-500 dark:text-gray-400">
29+
Flip the badges to see details. Locked badges show your progress.
30+
</p>
31+
</div>
32+
<AchievementsSection achievements={achievements} />
33+
</main>
34+
</DynamicGridBackground>
35+
);
36+
}

0 commit comments

Comments
 (0)