diff --git a/.env.example b/.env.example index d3cf7d5..4609fd6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,41 @@ +# ───────────────────────────────────────────────────────────── +# EduPilot — Environment Variables Template +# Copy this file to .env.local and fill in your values +# NEVER commit .env.local to version control +# ───────────────────────────────────────────────────────────── + +# ── Supabase ────────────────────────────────────────────────── +# Found in: Supabase Dashboard → Project Settings → API + +# Your project URL (safe to expose to client) +NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co + +# Anon/public key (safe to expose to client) +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here + +# Service role key — NEVER expose to client, server-side only +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + +# ── Razorpay ────────────────────────────────────────────────── +# Found in: Razorpay Dashboard → Settings → API Keys +# Use TEST keys during development + +# Test key ID (starts with rzp_test_) +RAZORPAY_KEY_ID=rzp_test_your_key_id_here + +# Secret key — server-side only, NEVER expose to client +RAZORPAY_SECRET_KEY=your_razorpay_secret_here + +# ── Google AI (Gemini) ──────────────────────────────────────── +# Get from: https://aistudio.google.com/app/apikey +GEMINI_API_KEY=your-gemini-api-key-here + +# ── App ─────────────────────────────────────────────────────── +# Your deployed URL (no trailing slash) +# Development: http://localhost:3000 +# Production: https://yourdomain.com +NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Supabase Configuration # Replace these values with your own working Supabase project keys NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-actual-supabase-anon-key \ No newline at end of file +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-actual-supabase-anon-key diff --git a/app/(dashboard)/goals/page.tsx b/app/(dashboard)/goals/page.tsx new file mode 100644 index 0000000..43f9468 --- /dev/null +++ b/app/(dashboard)/goals/page.tsx @@ -0,0 +1,400 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import type { StudyGoal, AchievementWithStatus, UserXp } from "@/types/goals"; +import { XPCard } from "@/components/dashboard/goals/xp-card"; +import { LevelCard } from "@/components/dashboard/goals/level-card"; +import { StatisticsCard } from "@/components/dashboard/goals/statistics-card"; +import { GoalCard } from "@/components/dashboard/goals/goal-card"; +import { GoalForm } from "@/components/dashboard/goals/goal-form"; +import { GoalHistory } from "@/components/dashboard/goals/goal-history"; +import { AchievementGrid } from "@/components/dashboard/goals/achievement-grid"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; +import { + Award, + Plus, + RefreshCw, + Trophy, + Target, +} from "lucide-react"; +import { useUser } from "@/hooks/use-user"; + +export default function GoalsPage() { + const { refetch: refetchUser } = useUser(); + + const [goals, setGoals] = useState([]); + const [achievements, setAchievements] = useState([]); + const [xp, setXp] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Form controls + const [isFormOpen, setIsFormOpen] = useState(false); + const [goalToEdit, setGoalToEdit] = useState(null); + + const loadData = async (silent = false) => { + if (!silent) setIsLoading(true); + setError(null); + try { + const [goalsRes, achievementsRes, xpRes] = await Promise.all([ + fetch("/api/goals", { cache: "no-store" }), + fetch("/api/achievements", { cache: "no-store" }), + fetch("/api/xp", { cache: "no-store" }), + ]); + + const [goalsJson, achievementsJson, xpJson] = await Promise.all([ + goalsRes.json(), + achievementsRes.json(), + xpRes.json(), + ]); + + if (!goalsRes.ok || !goalsJson.success) { + throw new Error(goalsJson.error || "Failed to load goals"); + } + if (!achievementsRes.ok || !achievementsJson.success) { + throw new Error(achievementsJson.error || "Failed to load achievements"); + } + if (!xpRes.ok || !xpJson.success) { + throw new Error(xpJson.error || "Failed to load XP"); + } + + setGoals(goalsJson.data || []); + setAchievements(achievementsJson.data || []); + setXp(xpJson.data || null); + } catch (err) { + console.error("[GoalsPage] Error loading data:", err); + setError(err instanceof Error ? err.message : "Failed to load dashboard data"); + } finally { + setIsLoading(false); + } + }; + + const triggerSweep = async () => { + try { + const res = await fetch("/api/achievements/check", { + method: "POST", + }); + const data = await res.json(); + if (res.ok && data.success) { + if (data.achievementsUnlocked && data.achievementsUnlocked.length > 0) { + data.achievementsUnlocked.forEach((badge: any) => { + toast.success(`Achievement Unlocked: ${badge.title}!`, { + description: badge.description, + icon: , + duration: 8000, + }); + }); + // Refresh data if achievements were unlocked during background sweep + await loadData(true); + void refetchUser(true, true); + } + } + } catch (err) { + console.error("[GoalsPage] Background achievements check error:", err); + } + }; + + useEffect(() => { + void loadData().then(() => { + void triggerSweep(); + }); + }, []); + + const handleCreateOrUpdateGoal = async (data: { + title: string; + description: string | null; + goal_type: "daily" | "weekly" | "monthly"; + target_value: number; + due_date: string | null; + }) => { + try { + let res; + if (goalToEdit) { + res = await fetch(`/api/goals/${goalToEdit.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + } else { + res = await fetch("/api/goals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + } + + const json = await res.json(); + if (!res.ok || !json.success) { + throw new Error(json.error || "Failed to save goal"); + } + + toast.success(goalToEdit ? "Goal updated successfully" : "Goal created successfully"); + + // Check if this action triggered any achievements + if (json.achievementsUnlocked && json.achievementsUnlocked.length > 0) { + json.achievementsUnlocked.forEach((badge: any) => { + toast.success(`Achievement Unlocked: ${badge.title}!`, { + description: badge.description, + icon: , + duration: 8000, + }); + }); + } + + setGoalToEdit(null); + await loadData(true); + void refetchUser(true, true); + } catch (err) { + console.error("[GoalsPage] Save goal error:", err); + toast.error(err instanceof Error ? err.message : "Failed to save goal"); + throw err; + } + }; + + const handleUpdateGoalProgress = async (id: string, updates: Partial) => { + try { + const res = await fetch(`/api/goals/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + + const json = await res.json(); + if (!res.ok || !json.success) { + throw new Error(json.error || "Failed to update goal progress"); + } + + // Special alert if goal completed + if (json.data && json.data.status === "completed" && goals.find(g => g.id === id)?.status === "pending") { + toast.success(`Goal Completed! +100 XP`, { + icon: , + }); + } + + // Alerts for unlocked achievements + if (json.achievementsUnlocked && json.achievementsUnlocked.length > 0) { + json.achievementsUnlocked.forEach((badge: any) => { + toast.success(`Achievement Unlocked: ${badge.title}!`, { + description: badge.description, + icon: , + duration: 8000, + }); + }); + } + + await loadData(true); + void refetchUser(true, true); + } catch (err) { + console.error("[GoalsPage] Progress update error:", err); + toast.error(err instanceof Error ? err.message : "Failed to update goal"); + } + }; + + const handleDeleteGoal = async (id: string) => { + try { + const res = await fetch(`/api/goals/${id}`, { + method: "DELETE", + }); + + const json = await res.json(); + if (!res.ok || !json.success) { + throw new Error(json.error || "Failed to delete goal"); + } + + toast.success("Goal deleted successfully"); + await loadData(true); + void refetchUser(true, true); + } catch (err) { + console.error("[GoalsPage] Delete goal error:", err); + toast.error(err instanceof Error ? err.message : "Failed to delete goal"); + } + }; + + const handleStartEdit = (goal: StudyGoal) => { + setGoalToEdit(goal); + setIsFormOpen(true); + }; + + const handleCloseForm = () => { + setGoalToEdit(null); + setIsFormOpen(false); + }; + + // Derived variables + const activeGoals = goals.filter((g) => g.status === "pending"); + const completedGoals = goals.filter((g) => g.status === "completed"); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ ); + } + + if (error) { + return ( +
+
+ +
+

Failed to load Tracker

+

{error}

+ +
+ ); + } + + return ( +
+ {/* Header Panel */} +
+
+

+ + AI Goal Tracker & Achievements +

+

+ Build habits, complete study goals, and unlock exclusive rewards. +

+
+ + +
+ + {/* Progress Cards */} +
+ + +
+ + {/* Statistics Section */} + a.earned).length} + totalAchievements={achievements.length} + /> + + {/* Main Workspace Layout */} +
+ {/* Left Hand: Goals Tracker & Achievements */} +
+ {/* Goals section */} +
+
+

+ + Goals Tracker +

+
+ + + + + Active ({activeGoals.length}) + + + Completed ({completedGoals.length}) + + + + + {activeGoals.length === 0 ? ( +
+ +

No active goals

+

+ Stay focused by setting your daily, weekly, or monthly study targets. +

+ +
+ ) : ( +
+ {activeGoals.map((goal) => ( + + ))} +
+ )} +
+ + + {completedGoals.length === 0 ? ( +
+ +

No completed goals yet

+

+ Mark your active goals as complete to move them here and earn +100 XP. +

+
+ ) : ( +
+ {completedGoals.map((goal) => ( + + ))} +
+ )} +
+
+
+ + {/* Achievement gallery */} + +
+ + {/* Right Hand Sidebar: Timeline & Completed History */} +
+ +
+
+ + {/* Goal creation Form dialog */} + +
+ ); +} diff --git a/app/(dashboard)/topic-analyzer/page.tsx b/app/(dashboard)/topic-analyzer/page.tsx new file mode 100644 index 0000000..da5cf0d --- /dev/null +++ b/app/(dashboard)/topic-analyzer/page.tsx @@ -0,0 +1,458 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { + Sparkles, + History, + Trash2, + Copy, + Download, + RotateCcw, + BookOpen, + Brain, + Gauge, + Clock, + RefreshCw, + HelpCircle, + TrendingUp, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { useUser } from "@/hooks/use-user" +import { TopicAnalyzerForm } from "@/components/topic-analyzer/topic-analyzer-form" +import { DifficultyBadge } from "@/components/topic-analyzer/difficulty-badge" +import { ConfidenceMeter } from "@/components/topic-analyzer/confidence-meter" +import { PrerequisitesList } from "@/components/topic-analyzer/prerequisites-list" +import { StudyTimeline } from "@/components/topic-analyzer/study-timeline" +import { RevisionCard } from "@/components/topic-analyzer/revision-card" +import { TipsCard } from "@/components/topic-analyzer/tips-card" +import { AnalysisSummary } from "@/components/topic-analyzer/analysis-summary" + +interface TopicAnalysisData { + id?: string + difficulty: "Beginner" | "Intermediate" | "Advanced" + estimatedHours: string + confidence: number + summary: string + studyOrder: string[] + prerequisites: string[] + relatedConcepts: string[] + revisionSessions: number + tips: string[] +} + +interface HistoryItem { + id: string + topic: string + analysis_json: TopicAnalysisData + created_at: string +} + +export default function TopicAnalyzerPage() { + const { refetch, subscription } = useUser() + + const [activeTopic, setActiveTopic] = useState("") + const [activeAnalysis, setActiveAnalysis] = useState(null) + const [history, setHistory] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [historyLoading, setHistoryLoading] = useState(true) + + useEffect(() => { + void loadHistory() + }, []) + + const loadHistory = async () => { + try { + setHistoryLoading(true) + const res = await fetch("/api/topic-analysis", { cache: "no-store" }) + const data = await res.json() + if (res.ok && data.success) { + setHistory(data.history || []) + } + } catch (err) { + console.error("Failed to load history:", err) + } finally { + setHistoryLoading(false) + } + } + + const handleAnalyze = async (topic: string) => { + setIsLoading(true) + setActiveAnalysis(null) + setActiveTopic(topic) + + try { + const res = await fetch("/api/topic-analysis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic }), + }) + + const data = await res.json() + + if (!res.ok) { + if (res.status === 402 || data.code === "NO_CREDITS") { + toast.error("You've run out of credits. Please upgrade your plan.", { + action: { + label: "Upgrade", + onClick: () => (window.location.href = "/billing"), + }, + }) + } else if (res.status === 429) { + toast.error("Rate limit reached. Please wait a minute and try again.") + } else { + toast.error(data.error || "An error occurred during topic analysis.") + } + setIsLoading(false) + return + } + + const analysis: TopicAnalysisData = { + id: data.id, + difficulty: data.difficulty, + estimatedHours: data.estimatedHours, + confidence: data.confidence, + summary: data.summary, + studyOrder: data.studyOrder, + prerequisites: data.prerequisites, + relatedConcepts: data.relatedConcepts, + revisionSessions: data.revisionSessions, + tips: data.tips, + } + + setActiveAnalysis(analysis) + toast.success(`Analysis for "${topic}" completed!`) + + // Refetch credits to keep UI updated + void refetch(true, true) + + // Add to local history list + const newHistoryItem: HistoryItem = { + id: data.id, + topic, + analysis_json: analysis, + created_at: new Date().toISOString(), + } + setHistory((prev) => [newHistoryItem, ...prev]) + } catch (err) { + console.error(err) + toast.error("Network error. Please check your connection and try again.") + } finally { + setIsLoading(false) + } + } + + const handleDeleteHistory = async (id: string, e: React.MouseEvent) => { + e.stopPropagation() + const previousHistory = [...history] + + // Optimistic Update + setHistory((prev) => prev.filter((item) => item.id !== id)) + if (activeAnalysis?.id === id) { + setActiveAnalysis(null) + setActiveTopic("") + } + + try { + const res = await fetch(`/api/topic-analysis?id=${id}`, { + method: "DELETE", + }) + if (!res.ok) { + throw new Error("Failed to delete history item") + } + toast.success("Analysis deleted.") + } catch (err) { + console.error(err) + setHistory(previousHistory) + toast.error("Failed to delete. Please try again.") + } + } + + const handleSelectHistory = (item: HistoryItem) => { + setActiveTopic(item.topic) + setActiveAnalysis(item.analysis_json) + } + + const handleCopyMarkdown = () => { + if (!activeAnalysis) return + + const markdown = `# Topic Analysis: ${activeTopic} + +## Overview +- **Difficulty**: ${activeAnalysis.difficulty} +- **Estimated Study Time**: ${activeAnalysis.estimatedHours} hours +- **Confidence Score**: ${activeAnalysis.confidence}% +- **Summary**: ${activeAnalysis.summary} + +## Learning Roadmap +${activeAnalysis.studyOrder.map((step, idx) => `${idx + 1}. ${step}`).join("\n")} + +## Prerequisites +${activeAnalysis.prerequisites.map((req) => `- ${req}`).join("\n")} + +## Related Concepts +${activeAnalysis.relatedConcepts.map((concept) => `- ${concept}`).join("\n")} + +## Recommended Revision Sessions +- **Sessions**: ${activeAnalysis.revisionSessions} +- Spaced repetition schedule recommended. + +## Preparation & Study Tips +${activeAnalysis.tips.map((tip) => `- ${tip}`).join("\n")} +` + + navigator.clipboard.writeText(markdown) + toast.success("Analysis copied as Markdown!") + } + + const handleDownloadJson = () => { + if (!activeAnalysis) return + + const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent( + JSON.stringify({ topic: activeTopic, ...activeAnalysis }, null, 2) + )}` + const downloadAnchor = document.createElement("a") + downloadAnchor.setAttribute("href", jsonString) + + const slug = activeTopic.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-") + downloadAnchor.setAttribute("download", `topic-analysis-${slug}.json`) + + document.body.appendChild(downloadAnchor) + downloadAnchor.click() + downloadAnchor.remove() + toast.success("JSON downloaded successfully.") + } + + const handleReset = () => { + setActiveTopic("") + setActiveAnalysis(null) + } + + return ( +
+ {/* Header Banner */} +
+
+

+ + AI Topic Difficulty Analyzer +

+

+ Map out learning roadmaps, prerequisites, revision sessions, and difficulty scores for any topic. +

+
+
+ +
+ {/* Left Side: Analysis History */} + + + + + Recent Analyses + + + + {historyLoading ? ( +
Loading history...
+ ) : history.length === 0 ? ( +
+ +

No history yet

+

+ Your analyzed topics will appear here. +

+
+ ) : ( + history.map((item) => { + const isActive = activeAnalysis?.id === item.id + + return ( +
handleSelectHistory(item)} + className={cn( + "group w-full rounded-xl border text-left p-3 cursor-pointer transition-all flex items-center justify-between gap-2", + isActive + ? "border-primary bg-primary/10 shadow-xs" + : "border-border bg-background/40 hover:border-primary/30 hover:bg-primary/5" + )} + > +
+

{item.topic}

+

+ {item.analysis_json.difficulty} · {item.analysis_json.estimatedHours} hrs +

+
+ +
+ ) + }) + )} +
+
+ + {/* Right Side: Workspace */} +
+ {/* Input Form Card */} + + + + + + + {/* Loading Skeleton Workspace */} + {isLoading && ( +
+ {/* Header card skeleton */} + + +
+
+
+
+
+ + + + {/* Main layouts skeletons */} +
+
+
+
+
+
+
+
+
+
+
+
+ )} + + {/* Results Workspace */} + {activeAnalysis && !isLoading && ( +
+ {/* Top Overview Bar */} + + +
+
+

{activeTopic}

+ +
+
+ + + Study Time: {activeAnalysis.estimatedHours} hours + + + + Revision: {activeAnalysis.revisionSessions} sessions + + + + Confidence: {activeAnalysis.confidence}% + +
+
+ {/* Gauge */} +
+ + Confidence +
+
+
+ + {/* Main Content Grid */} +
+
+ {/* Summary */} + + + {/* Prerequisites & Related Concepts */} + + + {/* Tips */} + +
+ +
+ {/* Timeline Roadmap */} + + + {/* Revision Schedule */} + +
+
+ + {/* Workspace Actions Panel */} +
+ + + +
+
+ )} + + {/* Empty State Workspace */} + {!activeAnalysis && !isLoading && ( + + +
+ +
+
+

Awaiting Topic Input

+

+ Type in any technical concepts, programming languages, or academic subjects. + EduPilot AI will map out the complete study profile, confidence score, and roadmap. +

+
+ {/* Suggestions block for quick-starts */} +
+ + + Try searching: + +
+ {["Deep Learning", "SQL Database Sharding", "TCP/IP Layer Model"].map((term) => ( + + ))} +
+
+
+
+ )} +
+
+
+ ) +} diff --git a/app/api/achievements/check/route.ts b/app/api/achievements/check/route.ts new file mode 100644 index 0000000..8d4cf0b --- /dev/null +++ b/app/api/achievements/check/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth-server"; +import { checkAndUnlockAchievements, getUserXp } from "@/lib/goals-db"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const checkResult = await checkAndUnlockAchievements(user.id); + const xp = await getUserXp(user.id); + + return NextResponse.json({ + success: true, + achievementsUnlocked: checkResult.newlyUnlocked, + xp, + }); + } catch (err) { + console.error("[api/achievements/check] POST Error:", err); + const msg = err instanceof Error ? err.message : "Failed to check achievements"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/achievements/route.ts b/app/api/achievements/route.ts new file mode 100644 index 0000000..cd6c91f --- /dev/null +++ b/app/api/achievements/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth-server"; +import { getAchievementsWithStatus } from "@/lib/goals-db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const achievements = await getAchievementsWithStatus(user.id); + return NextResponse.json({ success: true, data: achievements }); + } catch (err) { + console.error("[api/achievements] GET Error:", err); + const msg = err instanceof Error ? err.message : "Failed to load achievements"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/ai/chat/route.ts b/app/api/ai/chat/route.ts index 75ebac1..7670e73 100644 --- a/app/api/ai/chat/route.ts +++ b/app/api/ai/chat/route.ts @@ -5,6 +5,7 @@ import { requireAiAccess } from "@/lib/ai-guard"; import { generateAIResponse } from "@/lib/ai"; import { logUsage } from "@/lib/database"; import { getSupabaseAdmin } from "@/lib/supabase-server"; +import { awardXp, checkAndUnlockAchievements, XP_VALUES } from "@/lib/goals-db"; import { analyzeAttachmentsWithGemini, searchWithTavily, @@ -191,7 +192,7 @@ export async function POST(req: NextRequest) { ); } - savedSessionId = currentSessionId; + savedSessionId = currentSessionId ?? null; logUsage( user.id, @@ -212,6 +213,14 @@ export async function POST(req: NextRequest) { ).catch((err) => { console.error("[ai/chat] Failed to log usage metrics:", err); }); + + // Award XP for AI Chat and trigger achievements sweep + awardXp(user.id, XP_VALUES.ai_chat).catch((err) => { + console.error("[ai/chat] Failed to award XP:", err); + }); + checkAndUnlockAchievements(user.id).catch((err) => { + console.error("[ai/chat] Failed to check achievements:", err); + }); } return NextResponse.json({ diff --git a/app/api/ai/flashcards/route.ts b/app/api/ai/flashcards/route.ts index cbb18eb..cb90c09 100644 --- a/app/api/ai/flashcards/route.ts +++ b/app/api/ai/flashcards/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server" import { getUser } from "@/lib/auth-server" import { generateFlashcards, generateFlashcardsFromContent } from "@/lib/ai" import { getSupabaseAdmin } from "@/lib/supabase-server" +import { awardXp, checkAndUnlockAchievements, XP_VALUES } from "@/lib/goals-db" import { logUsage, getSubscription, @@ -150,6 +151,14 @@ export async function POST(req: NextRequest) { sourceId: isFromSource ? sourceId : null, }).catch(console.error) + // Award XP for Flashcards and check achievements + awardXp(user.id, XP_VALUES.flashcards).catch((err) => { + console.error("[ai/flashcards] Failed to award XP:", err); + }); + checkAndUnlockAchievements(user.id).catch((err) => { + console.error("[ai/flashcards] Failed to check achievements:", err); + }); + return NextResponse.json({ success: true, flashcards, savedSet }) } catch (err) { console.error("[ai/flashcards] Error:", err) diff --git a/app/api/ai/planner/route.ts b/app/api/ai/planner/route.ts index 29e67da..d57d113 100644 --- a/app/api/ai/planner/route.ts +++ b/app/api/ai/planner/route.ts @@ -9,6 +9,7 @@ import { getSubscription, isTrialActive, } from "@/lib/database" +import { awardXp, checkAndUnlockAchievements, XP_VALUES } from "@/lib/goals-db" type PlannerTaskInput = { id: string @@ -110,6 +111,14 @@ export async function POST(req: NextRequest) { taskCount: normalizedTasks.length, }).catch(() => undefined) + // Award XP for study plan saved and check achievements + awardXp(user.id, XP_VALUES.study_plan).catch((err) => { + console.error("[ai/planner] Failed to award XP:", err); + }); + checkAndUnlockAchievements(user.id).catch((err) => { + console.error("[ai/planner] Failed to check achievements:", err); + }); + return NextResponse.json({ success: true, plan: savedPlan, diff --git a/app/api/goals/[id]/route.ts b/app/api/goals/[id]/route.ts new file mode 100644 index 0000000..7c2932b --- /dev/null +++ b/app/api/goals/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth-server"; +import { updateGoal, deleteGoal } from "@/lib/goals-db"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const updateGoalSchema = z.object({ + title: z.string().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), + goal_type: z.enum(["daily", "weekly", "monthly"]).optional(), + target_value: z.number().int().min(1).optional(), + current_value: z.number().int().min(0).optional(), + status: z.enum(["pending", "completed"]).optional(), + due_date: z.string().datetime().nullable().optional(), +}); + +export async function PATCH( + req: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + const body = await req.json().catch(() => ({})); + const result = updateGoalSchema.safeParse(body); + + if (!result.success) { + const errorMsg = result.error.errors.map((e) => e.message).join(", "); + return NextResponse.json({ error: errorMsg }, { status: 400 }); + } + + const updateResult = await updateGoal(user.id, id, result.data); + + return NextResponse.json({ + success: true, + data: updateResult.goal, + xpAwarded: updateResult.xpAwarded, + achievementsUnlocked: updateResult.achievementsUnlocked, + }); + } catch (err) { + console.error("[api/goals/[id]] PATCH Error:", err); + const msg = err instanceof Error ? err.message : "Failed to update goal"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function DELETE( + _req: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + await deleteGoal(user.id, id); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[api/goals/[id]] DELETE Error:", err); + const msg = err instanceof Error ? err.message : "Failed to delete goal"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/goals/route.ts b/app/api/goals/route.ts new file mode 100644 index 0000000..b246155 --- /dev/null +++ b/app/api/goals/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth-server"; +import { getGoals, createGoal, checkAndUnlockAchievements } from "@/lib/goals-db"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const createGoalSchema = z.object({ + title: z.string().min(1, "Title is required").max(100, "Title is too long"), + description: z.string().max(500, "Description is too long").nullable().optional(), + goal_type: z.enum(["daily", "weekly", "monthly"]), + target_value: z.number().int().min(1, "Target value must be at least 1"), + due_date: z.string().datetime().nullable().optional(), +}); + +export async function GET() { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const goals = await getGoals(user.id); + return NextResponse.json({ success: true, data: goals }); + } catch (err) { + console.error("[api/goals] GET Error:", err); + const msg = err instanceof Error ? err.message : "Failed to load goals"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const result = createGoalSchema.safeParse(body); + + if (!result.success) { + const errorMsg = result.error.errors.map((e) => e.message).join(", "); + return NextResponse.json({ error: errorMsg }, { status: 400 }); + } + + const goal = await createGoal(user.id, result.data); + + // Check if goal creation triggered the "Goal Setter" achievement + const sweepResult = await checkAndUnlockAchievements(user.id); + + return NextResponse.json({ + success: true, + data: goal, + achievementsUnlocked: sweepResult.newlyUnlocked + }); + } catch (err) { + console.error("[api/goals] POST Error:", err); + const msg = err instanceof Error ? err.message : "Failed to create goal"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/topic-analysis/route.ts b/app/api/topic-analysis/route.ts new file mode 100644 index 0000000..6d8cb57 --- /dev/null +++ b/app/api/topic-analysis/route.ts @@ -0,0 +1,117 @@ +export const dynamic = "force-dynamic" + +import { NextRequest, NextResponse } from "next/server" +import { requireAiAccess } from "@/lib/ai-guard" +import { getUser } from "@/lib/auth-server" +import { analyzeTopic } from "@/lib/ai" +import { + getTopicAnalysisHistory, + saveTopicAnalysis, + deleteTopicAnalysis, + logUsage, +} from "@/lib/database" + +export async function GET() { + try { + const user = await getUser() + + if (!user) { + return NextResponse.json( + { error: "Login required", code: "UNAUTHORIZED" }, + { status: 401 } + ) + } + + const history = await getTopicAnalysisHistory(user.id) + return NextResponse.json({ success: true, history }) + } catch (err) { + console.error("[api/topic-analysis] GET Error:", err) + const message = err instanceof Error ? err.message : "Failed to load analysis history" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function POST(req: NextRequest) { + try { + // 1. Guard access (Auth, Rate Limiting, 1 AI Chat Credit consumption) + const guard = await requireAiAccess("ai_chat") + if (guard.error) return guard.error + const { user } = guard + + // 2. Parse and validate input + const body = await req.json().catch(() => ({})) + const topic = body.topic + + if (!topic || typeof topic !== "string" || topic.trim().length === 0) { + return NextResponse.json( + { error: "Topic is required and must be a valid string." }, + { status: 400 } + ) + } + + const cleanTopic = topic.trim() + if (cleanTopic.length > 100) { + return NextResponse.json( + { error: "Topic input is too long (maximum 100 characters)." }, + { status: 400 } + ) + } + + // 3. Generate analysis from AI + const analysisResult = await analyzeTopic(cleanTopic) + + // 4. Save to user history in DB + const savedRecord = await saveTopicAnalysis(user.id, cleanTopic, analysisResult) + + // 5. Log usage metrics + await logUsage(user.id, "topic_analyzer", "topic_analyzed", { + topic: cleanTopic, + difficulty: analysisResult.difficulty, + confidence: analysisResult.confidence, + savedId: savedRecord.id, + }).catch((err) => { + console.error("[api/topic-analysis] Failed to log usage metrics:", err) + }) + + // 6. Return response matching requested schema, augmented with record details + return NextResponse.json({ + success: true, + id: savedRecord.id, + ...analysisResult, + }) + } catch (err) { + console.error("[api/topic-analysis] POST Error:", err) + const message = err instanceof Error ? err.message : "Failed to analyze topic" + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest) { + try { + const user = await getUser() + + if (!user) { + return NextResponse.json( + { error: "Login required", code: "UNAUTHORIZED" }, + { status: 401 } + ) + } + + const { searchParams } = new URL(req.url) + const id = searchParams.get("id") + + if (!id) { + return NextResponse.json( + { error: "Analysis ID is required" }, + { status: 400 } + ) + } + + await deleteTopicAnalysis(user.id, id) + return NextResponse.json({ success: true }) + } catch (err) { + console.error("[api/topic-analysis] DELETE Error:", err) + const message = err instanceof Error ? err.message : "Failed to delete history item" + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/xp/route.ts b/app/api/xp/route.ts new file mode 100644 index 0000000..e19b086 --- /dev/null +++ b/app/api/xp/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth-server"; +import { getUserXp } from "@/lib/goals-db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const user = await getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const xp = await getUserXp(user.id); + return NextResponse.json({ success: true, data: xp }); + } catch (err) { + console.error("[api/xp] GET Error:", err); + const msg = err instanceof Error ? err.message : "Failed to load XP"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/components/dashboard/goals/achievement-badge.tsx b/components/dashboard/goals/achievement-badge.tsx new file mode 100644 index 0000000..2ac001e --- /dev/null +++ b/components/dashboard/goals/achievement-badge.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import type { AchievementWithStatus } from "@/types/goals"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import * as Icons from "lucide-react"; + +interface AchievementBadgeProps { + achievement: AchievementWithStatus; +} + +export function AchievementBadge({ achievement }: AchievementBadgeProps) { + // Dynamically resolve icon from lucide-react + const IconComponent = (Icons as any)[achievement.icon] || Icons.Award; + const isUnlocked = achievement.earned; + + // Grade color configs + const categoryGradients: Record = { + first_ai_chat: "from-blue-500 to-indigo-500 shadow-blue-500/20", + ten_flashcard_sets: "from-emerald-500 to-teal-500 shadow-emerald-500/20", + five_study_plans: "from-cyan-500 to-blue-500 shadow-cyan-500/20", + seven_day_streak: "from-orange-500 to-red-500 shadow-orange-500/20", + one_hundred_ai_questions: "from-yellow-500 to-amber-600 shadow-yellow-500/20", + goal_setter: "from-sky-500 to-indigo-500 shadow-sky-500/20", + first_goal_completed: "from-emerald-400 to-emerald-600 shadow-emerald-500/20", + twenty_five_goals_completed: "from-purple-500 to-pink-500 shadow-purple-500/20", + }; + + const gradient = categoryGradients[achievement.id] || "from-primary to-chart-3 shadow-primary/20"; + + const earnedDateFormatted = achievement.earned_at + ? new Date(achievement.earned_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : null; + + return ( + + + + + {isUnlocked && ( +
+ )} + + {/* Badge Icon circle */} +
+ {isUnlocked ? ( + + ) : ( + + )} +
+ + {/* Title & Desc */} +
+
+ {achievement.title} +
+

+ {achievement.description} +

+
+ + {/* Progress or Earned Date */} +
+ {isUnlocked ? ( + + Unlocked {earnedDateFormatted} + + ) : ( +
+
+ Progress + + {achievement.current_value}/{achievement.required_value} + +
+
+
+
+
+ )} +
+ + + + +

{achievement.title}

+

{achievement.description}

+

+ + Reward: +{achievement.xp_reward} XP +

+
+ + + ); +} diff --git a/components/dashboard/goals/achievement-grid.tsx b/components/dashboard/goals/achievement-grid.tsx new file mode 100644 index 0000000..7d35a17 --- /dev/null +++ b/components/dashboard/goals/achievement-grid.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +import type { AchievementWithStatus } from "@/types/goals"; +import { AchievementBadge } from "./achievement-badge"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent } from "@/components/ui/card"; +import { Award, Lock, Sparkles } from "lucide-react"; + +interface AchievementGridProps { + achievements: AchievementWithStatus[]; +} + +export function AchievementGrid({ achievements }: AchievementGridProps) { + const [filter, setFilter] = useState<"all" | "earned" | "locked">("all"); + + const earned = achievements.filter((a) => a.earned); + const locked = achievements.filter((a) => !a.earned); + + const displayedAchievements = + filter === "earned" ? earned : filter === "locked" ? locked : achievements; + + return ( +
+ {/* Header and Filter Tabs */} +
+
+

+ + Achievements Gallery +

+

+ Complete study milestones to unlock badges and earn bonus XP. +

+
+ + setFilter(val)} className="w-full sm:w-auto"> + + + All ({achievements.length}) + + + Earned ({earned.length}) + + + Locked ({locked.length}) + + + +
+ + {/* Grid */} + {displayedAchievements.length === 0 ? ( + + + {filter === "earned" ? ( + <> + +

No badges earned yet

+

Complete goals and start studying to earn your first badge!

+ + ) : ( + <> + +

No locked achievements

+

Amazing job! You have unlocked all achievement badges!

+ + )} +
+
+ ) : ( +
+ {displayedAchievements.map((achievement) => ( + + ))} +
+ )} +
+ ); +} diff --git a/components/dashboard/goals/goal-card.tsx b/components/dashboard/goals/goal-card.tsx new file mode 100644 index 0000000..319c589 --- /dev/null +++ b/components/dashboard/goals/goal-card.tsx @@ -0,0 +1,184 @@ +import React, { useState } from "react"; +import type { StudyGoal } from "@/types/goals"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { GoalProgress } from "./goal-progress"; +import { Badge } from "@/components/ui/badge"; +import { + Calendar, + CheckCircle2, + Edit2, + Minus, + Plus, + Trash2, +} from "lucide-react"; + +interface GoalCardProps { + goal: StudyGoal; + onUpdate: (id: string, updates: Partial) => Promise; + onDelete: (id: string) => Promise; + onEdit: (goal: StudyGoal) => void; +} + +export function GoalCard({ goal, onUpdate, onDelete, onEdit }: GoalCardProps) { + const [updating, setUpdating] = useState(false); + + const isCompleted = goal.status === "completed"; + + // Type styling configuration + const typeConfigs = { + daily: { label: "Daily", class: "bg-blue-500/10 text-blue-500 hover:bg-blue-500/15" }, + weekly: { label: "Weekly", class: "bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/15" }, + monthly: { label: "Monthly", class: "bg-purple-500/10 text-purple-500 hover:bg-purple-500/15" }, + }; + + const currentConfig = typeConfigs[goal.goal_type] || typeConfigs.daily; + + const handleQuickProgress = async (increment: boolean) => { + if (updating) return; + setUpdating(true); + try { + const delta = increment ? 1 : -1; + const nextValue = Math.max(0, goal.current_value + delta); + await onUpdate(goal.id, { current_value: nextValue }); + } catch (err) { + console.error("[GoalCard] Quick progress error:", err); + } finally { + setUpdating(false); + } + }; + + const handleMarkComplete = async () => { + if (updating) return; + setUpdating(true); + try { + await onUpdate(goal.id, { status: "completed", current_value: goal.target_value }); + } catch (err) { + console.error("[GoalCard] Mark complete error:", err); + } finally { + setUpdating(false); + } + }; + + const handleDelete = async () => { + if (confirm("Are you sure you want to delete this goal?")) { + await onDelete(goal.id); + } + }; + + const formatDueDate = (dateStr: string | null) => { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return null; + } + }; + + const dueDateFormatted = formatDueDate(goal.due_date); + + return ( + + {isCompleted && ( +
+ +
+ )} + + {/* Card Header Info */} +
+
+
+ + {currentConfig.label} + + {dueDateFormatted && ( +
+ + Due {dueDateFormatted} +
+ )} +
+

+ {goal.title} +

+ {goal.description && ( +

+ {goal.description} +

+ )} +
+ +
+ + +
+
+ + {/* Progress Bar */} + + + {/* Quick Actions */} + {!isCompleted && ( +
+
+ + +
+ + +
+ )} + + {isCompleted && ( +
+ + Completed +
+ )} +
+
+ ); +} diff --git a/components/dashboard/goals/goal-form.tsx b/components/dashboard/goals/goal-form.tsx new file mode 100644 index 0000000..3139d12 --- /dev/null +++ b/components/dashboard/goals/goal-form.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useState } from "react"; +import type { StudyGoal } from "@/types/goals"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface GoalFormProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: { + title: string; + description: string | null; + goal_type: "daily" | "weekly" | "monthly"; + target_value: number; + due_date: string | null; + }) => Promise; + goalToEdit: StudyGoal | null; +} + +export function GoalForm({ isOpen, onClose, onSubmit, goalToEdit }: GoalFormProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [goalType, setGoalType] = useState<"daily" | "weekly" | "monthly">("daily"); + const [targetValue, setTargetValue] = useState(1); + const [dueDate, setDueDate] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Set default due dates based on goal type + const getDefaultDueDate = (type: "daily" | "weekly" | "monthly") => { + const d = new Date(); + if (type === "daily") { + d.setHours(23, 59, 59, 999); + } else if (type === "weekly") { + // End of current week (e.g. Sunday) + const day = d.getDay(); + const diff = d.getDate() + (7 - day); + d.setDate(diff); + d.setHours(23, 59, 59, 999); + } else if (type === "monthly") { + // Last day of month + const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0); + d.setDate(lastDay.getDate()); + d.setHours(23, 59, 59, 999); + } + return d.toISOString().split("T")[0]; + }; + + useEffect(() => { + if (isOpen) { + if (goalToEdit) { + setTitle(goalToEdit.title); + setDescription(goalToEdit.description || ""); + setGoalType(goalToEdit.goal_type); + setTargetValue(goalToEdit.target_value); + setDueDate(goalToEdit.due_date ? new Date(goalToEdit.due_date).toISOString().split("T")[0] : ""); + } else { + setTitle(""); + setDescription(""); + setGoalType("daily"); + setTargetValue(1); + setDueDate(getDefaultDueDate("daily")); + } + setError(null); + } + }, [isOpen, goalToEdit]); + + const handleGoalTypeChange = (value: "daily" | "weekly" | "monthly") => { + setGoalType(value); + // If not editing, set corresponding default due date + if (!goalToEdit) { + setDueDate(getDefaultDueDate(value)); + } + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) { + setError("Title is required"); + return; + } + if (targetValue < 1) { + setError("Target value must be at least 1"); + return; + } + + setSubmitting(true); + setError(null); + + try { + let formattedDueDate = null; + if (dueDate) { + const d = new Date(dueDate); + d.setHours(23, 59, 59, 999); + formattedDueDate = d.toISOString(); + } + + await onSubmit({ + title: title.trim(), + description: description.trim() || null, + goal_type: goalType, + target_value: targetValue, + due_date: formattedDueDate, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save goal"); + } finally { + setSubmitting(false); + } + }; + + return ( + !open && onClose()}> + + + + {goalToEdit ? "Edit Study Goal" : "Create Study Goal"} + + + Set study targets for your study tracker. + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setTitle(e.target.value)} + placeholder="e.g. Study Chemistry Chapter 3" + disabled={submitting} + className="bg-secondary/40 border-border text-foreground" + /> +
+ +
+ +