Your next suggested focus will appear here.
+{module.roadmap.title}
+{q.question}
+{result.passed ? "Passed!" : "Try again."} Score: {result.score}
+ )} +Structured roadmaps, gamification, and community feedback to accelerate your learning.
+ +Weekly modules with objectives, curated resources, quizzes, and mini-projects.
+Earn XP, levels, badges, and maintain streaks. Climb the leaderboard.
+{project.module.roadmap.title} • Week {project.module.weekNumber}
+{project.description}
+ {project.repoUrl && ( + + )} +{roadmap.skill} • {roadmap.goal}
+{r.skill} • {r.goal}
+ + ))} +{c.user?.name || "User"}{c.user?.role === "mentor" && Mentor}
+{c.content}
+Level {level} • {xp} XP • Streak {streak}d
+{xp % 1000} / 1000 XP to next level
+Badges
+{q.question}
+{result.passed ? "Passed!" : "Try again."} Score: {result.score}
+ )} +Week {m.weekNumber}
+Locked — requires {m.unlockCondition}
+ )} +Sign in to LearnFlow
`, + }); + if (!process.env.EMAIL_SERVER) { + // eslint-disable-next-line no-console + console.log("Dev email link:", (result as any)?.message || url); + } + }, + }), + ...(process.env.GITHUB_ID && process.env.GITHUB_SECRET + ? [ + GitHubProvider({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + ] + : []), + ], + callbacks: { + async session({ session, token }) { + if (token?.sub) { + // @ts-expect-error augment at runtime + session.user.id = token.sub; + } + return session; + }, + }, +}; diff --git a/learnflow/src/lib/gamification.ts b/learnflow/src/lib/gamification.ts new file mode 100644 index 0000000..483e955 --- /dev/null +++ b/learnflow/src/lib/gamification.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/prisma"; +import { addDays, isSameDay } from "date-fns"; + +export const LEVEL_XP = 1000; // configurable + +export function computeLevel(xp: number): number { + return Math.floor(xp / LEVEL_XP) + 1; +} + +export async function awardXp(userId: string, amount: number, reason: string) { + const user = await prisma.user.update({ + where: { id: userId }, + data: { xp: { increment: amount } }, + }); + const level = computeLevel(user.xp); + if (level !== user.level) { + await prisma.user.update({ where: { id: userId }, data: { level } }); + } + await prisma.activityLog.create({ + data: { userId, action: "award_xp", meta: { amount, reason } as any }, + }); +} + +export async function updateStreak(userId: string) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return; + const today = new Date(); + if (user.streakLastActive && isSameDay(user.streakLastActive, today)) return; + let newStreak = 1; + if (user.streakLastActive && isSameDay(addDays(user.streakLastActive, 1), today)) { + newStreak = user.currentStreak + 1; + } + await prisma.user.update({ + where: { id: userId }, + data: { currentStreak: newStreak, streakLastActive: today }, + }); + // Daily +10 XP + await awardXp(userId, 10, "daily_streak"); + // 7-day bonus + if (newStreak % 7 === 0) { + await awardXp(userId, 200, "streak_bonus_7"); + } +} + +export async function maybeAwardBadge(userId: string, badgeName: string) { + const badge = await prisma.badge.findUnique({ where: { name: badgeName } }); + if (!badge) return; + const existing = await prisma.userBadge.findFirst({ where: { userId, badgeId: badge.id } }); + if (!existing) { + await prisma.userBadge.create({ data: { userId, badgeId: badge.id } }); + } +} diff --git a/learnflow/src/lib/locking.ts b/learnflow/src/lib/locking.ts new file mode 100644 index 0000000..3f0fcb4 --- /dev/null +++ b/learnflow/src/lib/locking.ts @@ -0,0 +1,30 @@ +import { prisma } from "@/lib/prisma"; + +export async function isModuleLockedForUser(moduleId: string, userId: string): Promise