From 04db17ea0e530b43e5ad23b18d706ae46a8cb9fb Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Tue, 7 Jul 2026 13:43:30 +0530 Subject: [PATCH] Implement active user tiers, class-based Tailwind theme switcher, 3-tiled static dashboard layout, Minio object storage integration, school & state settings database sync, and streak wishes onboarding modal --- backend/.gitignore | 1 + backend/celerybeat-schedule | Bin 16384 -> 0 bytes backend/main.py | 26 +- backend/requirements.txt | 1 + backend/seed.py | 57 +++- backend/storage.py | 91 +++++ docker-compose.yml | 24 ++ next.config.ts | 9 +- src/app/dashboard/ai-coach/page.tsx | 49 ++- src/app/dashboard/cbt/page.tsx | 75 +++- src/app/dashboard/layout.tsx | 165 +++++++-- src/app/dashboard/settings/page.tsx | 323 +++++++++++------- src/app/globals.css | 30 +- src/app/layout.tsx | 15 + src/app/parent/dashboard/page.tsx | 9 +- src/app/upgrade/page.tsx | 13 +- src/components/AI/CollegePredictorCard.tsx | 9 +- src/components/AI/ReadinessGauge.tsx | 9 +- src/components/AI/ReferralCard.tsx | 10 +- src/components/AI/StudyPlanCard.tsx | 9 +- src/components/AI/TutorChat.tsx | 10 +- .../Dashboard/StreakOnboardingModal.tsx | 92 +++++ src/hooks/useActiveUser.ts | 118 +++++++ src/store/cbtStore.ts | 4 +- 24 files changed, 913 insertions(+), 236 deletions(-) delete mode 100644 backend/celerybeat-schedule create mode 100644 backend/storage.py create mode 100644 src/components/Dashboard/StreakOnboardingModal.tsx create mode 100644 src/hooks/useActiveUser.ts diff --git a/backend/.gitignore b/backend/.gitignore index 18d787d..4900981 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -10,3 +10,4 @@ venv/ ENV/ env.bak/ venv.bak/ +celerybeat-schedule* diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule deleted file mode 100644 index a9c5eb0de4e2d7cc68bed6db2ca0aa63e0902b36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI3&x_MQ6vq?k+TCrH!V0SvL{#t>&ANzQRYXv7Q(?h_VUmpPu-VCyOoSE+J&D42 z+S!YLfM@X^@a)Myz>`-G{sn@*>7-i3%lczM`37d*W758P{q(&rlSAIU*jVv8o;Mw2 ztX-<@OKY#&$@4xtgBb)s00ck)1V8`;KmY_l00ck)1VG?lByh4$8rFdT2XL?iPzR_3 z)B*YcIshGj4nPN>1JD8J0CeC#(}4r7tG#w@y-Ut14?FvOeRkvbZG%F5q3dUnuf!y{4`-w3F>lxU*P?BY&;bAa;SbK>> z{dxPJ3tmndV-wi(Z&8X|M$x3PJADg)GS&-Mg3G~G6Q~ibc4{EHeWMg?EzGk`rkDw= zHgn11jHi>v)8Jd!-7{1~_8J>~s?Q}anBu)sRq3#|oiSPVIBh!YZRLd&vTt-N;TbIi zF=xu?L-&#aPpgdIqCAIY$DAAZ`uHqPlroVGqiM8g8qMg%lr~Uei_g0|=H_zqWAoth z>DAV;Yjh{BM5cr+jc)e2VoDVhTQ81s$%TBBlly?&JJzN3rKpnIBH7Hid1CIGAAWo2 z+eJG str: + """Uploads file content to Minio and returns the direct public URL.""" + try: + ensure_bucket_exists() + s3_client.put_object( + Bucket=MINIO_BUCKET, + Key=object_name, + Body=file_content, + ContentType=content_type + ) + + # Construct public URL (from browser's point of view, it connects to localhost:9000) + # We can resolve this dynamically by returning a path like /api/uploads/ or direct localhost:9000 link + public_url = f"http://localhost:9000/{MINIO_BUCKET}/{object_name}" + logger.info(f"Successfully uploaded {object_name} to Minio. URL: {public_url}") + return public_url + except Exception as e: + logger.error(f"Failed to upload file to Minio: {e}") + return "" + +def get_file(object_name: str) -> bytes: + """Downloads file content from Minio.""" + try: + response = s3_client.get_object(Bucket=MINIO_BUCKET, Key=object_name) + return response["Body"].read() + except Exception as e: + logger.error(f"Failed to get file from Minio: {e}") + return b"" diff --git a/docker-compose.yml b/docker-compose.yml index 6a1b3f2..1a59856 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,10 @@ services: - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 - ENV=development + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minio_admin + - MINIO_SECRET_KEY=minio_secure_password_2026 + - MINIO_BUCKET=pragativeer-uploads env_file: - ./backend/.env depends_on: @@ -63,6 +67,10 @@ services: environment: - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minio_admin + - MINIO_SECRET_KEY=minio_secure_password_2026 + - MINIO_BUCKET=pragativeer-uploads env_file: - ./backend/.env depends_on: @@ -107,9 +115,25 @@ services: networks: - pragativeer_network + minio: + image: minio/minio:latest + container_name: pragativeer_storage + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + environment: + MINIO_ROOT_USER: minio_admin + MINIO_ROOT_PASSWORD: minio_secure_password_2026 + command: server /data --console-address ":9001" + networks: + - pragativeer_network + volumes: pgdata: redisdata: + miniodata: networks: pragativeer_network: diff --git a/next.config.ts b/next.config.ts index e9ffa30..93c0267 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://backend:8000/api/:path*", + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/dashboard/ai-coach/page.tsx b/src/app/dashboard/ai-coach/page.tsx index 2e23754..dfb768c 100644 --- a/src/app/dashboard/ai-coach/page.tsx +++ b/src/app/dashboard/ai-coach/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { useEffect, useState } from "react" import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap" @@ -11,20 +11,22 @@ import { ProgressShare } from "@/components/AI/ProgressShare" import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2 } from "lucide-react" - -// Hardcode mock UUID for demo fetching -const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" +import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2, Lock } from "lucide-react" +import { useActiveUser } from "@/hooks/useActiveUser" +import Link from "next/link" export default function AICoachDashboard() { const [mistakes, setMistakes] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [loading, setLoading] = useState(true) + const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser(); + const fetchMistakes = async () => { + if (!userId) return setLoading(true) try { - const res = await fetch(`http://localhost:8000/api/mistakes/today?user_id=${USER_ID}`) + const res = await fetch(`/api/mistakes/today?user_id=${userId}`) if (!res.ok) throw new Error("Failed to fetch") const data = await res.json() setMistakes(data) @@ -38,11 +40,13 @@ export default function AICoachDashboard() { } useEffect(() => { - fetchMistakes() - }, []) + if (!userLoading && userId) { + fetchMistakes() + } + }, [userId, userLoading]) const submitReview = async (mistakeId: string, selectedAnswer: string) => { - const res = await fetch("http://localhost:8000/api/mistakes/review", { + const res = await fetch("/api/mistakes/review", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer }) @@ -61,6 +65,33 @@ export default function AICoachDashboard() { } } + if (userLoading) { + return
Loading AI Coach...
+ } + + if (!hasAccessTo("ai-mistake-tracking")) { + return ( +
+
+ +
+
+

AI Coach Workspace

+

+ Mistake tracking, Spaced Repetition (SM-2), AI Tutor Chats, and Readiness scores are premium features reserved for **AI Coach** subscribers. +

+
+
+ + + +
+
+ ) + } + return (
diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index db309ec..361c61a 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Activity, Clock, PlayCircle, Search } from "lucide-react"; +import { Activity, Clock, PlayCircle, Search, Lock } from "lucide-react"; +import { useActiveUser } from "@/hooks/useActiveUser"; interface TestData { id: string; @@ -22,27 +23,31 @@ export default function CBTPage() { const [search, setSearch] = useState(""); const [targetExam, setTargetExam] = useState(""); const [attempts, setAttempts] = useState([]); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); - const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser(); useEffect(() => { + if (userLoading || !userId) return; + async function loadCBTExams() { try { // 1. Fetch user target exam - const userRes = await fetch(`http://localhost:8000/api/users/${userId}`); + const userRes = await fetch(`/api/users/${userId}`); if (!userRes.ok) throw new Error("Failed to load user target exam"); const userData = await userRes.json(); const pattern = userData.target_exam; setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main"); // 2. Fetch tests filtered by exam - const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`); - if (!testRes.ok) throw new Error("Failed to fetch tests"); - const testsData = await testRes.json(); - setExams(testsData); + const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`); + if (testRes.ok) { + const testsData = await testRes.json(); + setExams(testsData); + } // 3. Fetch user attempts - const attemptsRes = await fetch(`http://localhost:8000/api/users/${userId}/attempts`); + const attemptsRes = await fetch(`/api/users/${userId}/attempts`); if (attemptsRes.ok) { const attemptsData = await attemptsRes.json(); setAttempts(attemptsData); @@ -54,7 +59,7 @@ export default function CBTPage() { } } loadCBTExams(); - }, []); + }, [userId, userLoading]); const filteredExams = exams.filter(exam => exam.title.toLowerCase().includes(search.toLowerCase()) @@ -109,19 +114,24 @@ export default function CBTPage() { {filteredExams.map((exam) => { const attempt = attemptsByTest[exam.id]; const isAttempted = !!attempt; + const isLocked = !hasAccessTo("weekly-test-series"); return ( - +
{exam.type} Test - {isAttempted && ( + {isLocked ? ( + + Premium + + ) : isAttempted ? ( Attempted - )} + ) : null}

{exam.title} @@ -130,11 +140,20 @@ export default function CBTPage() {
Available Now
{exam.total_time} Min • {exam.difficulty} Difficulty

- - - + ) : ( + + + + )} )})} @@ -185,6 +204,30 @@ export default function CBTPage() { )})} + + {showUpgradeModal && ( +
+
+
+ +
+

Upgrade to Premium Plan

+

+ This CBT Mock Test is a premium feature. Free users get access to 1 Daily Practice Sheet and 5 Topic-wise Micro-tests. Upgrade to Pro or AI Coach to unlock unlimited practice and full-length weekly test series! +

+
+ + + + +
+
+
+ )} ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 30584e3..7a90eb9 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,7 +1,7 @@ "use client" import { ReactNode, useState, useEffect, useRef } from "react" -import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, LogOut, User, ChevronUp } from "lucide-react" +import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, LogOut, User, ChevronUp, Lock } from "lucide-react" import Link from "next/link" import Image from "next/image" import { usePathname } from "next/navigation" @@ -10,11 +10,16 @@ import { ThemeToggle } from "@/components/ThemeToggle" import { TierBadge } from "@/components/TierBadge" import { StreakCounter } from "@/components/StreakCounter" import { AITutorChat } from "@/components/AI/AITutorChat" +import { useActiveUser, DEMO_USERS } from "@/hooks/useActiveUser" +import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal" +import { Badge } from "@/components/ui/badge" export default function DashboardLayout({ children }: { children: ReactNode }) { const pathname = usePathname() const [dropdownOpen, setDropdownOpen] = useState(false) const dropdownRef = useRef(null) + + const { userId, user, switchUser, hasAccessTo, loading } = useActiveUser(); // Close dropdown when clicking outside useEffect(() => { @@ -27,6 +32,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { return () => document.removeEventListener("mousedown", handleClickOutside) }, []) + const userInitials = user ? user.name.split(" ").map(n => n[0]).join("").toUpperCase() : "DS" + const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE Main" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE" + return (
{/* Sidebar */} @@ -51,7 +59,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
-
- - - -
- - {dropdownOpen && ( + {dropdownOpen && user && (
-

Demo Student

-

demo@pragativeer.com

+

{user.name}

+

{user.email || user.phone}

Edit Profile - - Log out - +
)} @@ -111,50 +141,111 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { onClick={() => setDropdownOpen(!dropdownOpen)} className="w-full flex items-center justify-between p-2 rounded-xl bg-background/50 hover:bg-background transition-colors border border-border/50 shadow-sm mt-3" > -
-
- DS +
+
+ {userInitials}
-
-

Demo Student

-
+
+

{user ? user.name.split(" ")[0] : "Loading..."}

+
- +
- {/* Main Content */} -
-
- - + {/* Main Content Area */} +
+ {/* Top Header Static Bar */} +
+
+

+ {user ? `Welcome back, ${user.name.split(" ")[0]}! 👋` : "Welcome back!"} +

+ {user && ( + + {targetExamLabel} Target + + )} +
+ +
+ {/* Streak count moved to top header */} + {user && ( + + + + )} + + {/* Profile switcher dropdown for testing */} +
+ Demo Switcher: + +
+ +
+ + +
+
-
+ + {/* Scrollable page body */} +
{children}
{/* Global AI Tutor Chat */} - + {hasAccessTo("ai-tutor-chat") && } + + {/* Onboarding Motivation Popup */} +
) } -function NavItem({ href, icon, label, active }: { href: string, icon: React.ReactNode, label: string, active?: boolean }) { +function NavItem({ href, icon, label, active, isLocked }: { href: string, icon: React.ReactNode, label: string, active?: boolean, isLocked?: boolean }) { return ( - {icon} - {label} +
+ {icon} + {label} +
+ {isLocked && } ) } + +function BrainCircuitIcon() { + return ( + + + + + + + + + + ) +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 600df78..d3b33aa 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,53 +1,85 @@ -"use client"; - import { useEffect, useState } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useActiveUser } from "@/hooks/useActiveUser"; +import { User as UserIcon, Upload, CheckCircle2 } from "lucide-react"; export default function SettingsPage() { - const [targetExam, setTargetExam] = useState<"JEE_Main" | "NEET">("JEE_Main"); - const [targetYear, setTargetYear] = useState("2026"); - const [firstName, setFirstName] = useState("Rahul"); - const [lastName, setLastName] = useState("Sharma"); - const [loading, setLoading] = useState(true); + const { userId, user, refreshUser, loading: userLoading } = useActiveUser(); + const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET">("JEE_Main"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [school, setSchool] = useState(""); + const [state, setState] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + + const [avatarUrl, setAvatarUrl] = useState(null); + const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); - - const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + const [loading, setLoading] = useState(true); useEffect(() => { - async function fetchUserData() { - try { - const res = await fetch(`http://localhost:8000/api/users/${userId}`); - if (res.ok) { - const data = await res.json(); - setTargetExam(data.target_exam === "NEET" ? "NEET" : "JEE_Main"); - if (data.name) { - const parts = data.name.split(" "); - setFirstName(parts[0] || ""); - setLastName(parts.slice(1).join(" ") || ""); - } - } - } catch (err) { - console.error("Failed to load user profile:", err); - } finally { - setLoading(false); - } + if (userLoading || !user) return; + + setTargetExam(user.target_exam); + setSchool(user.school || ""); + setState(user.state || ""); + setEmail(user.email || ""); + setPhone(user.phone || ""); + setAvatarUrl(localStorage.getItem(`user_avatar_${user.id}`) || null); + + if (user.name) { + const parts = user.name.split(" "); + setFirstName(parts[0] || ""); + setLastName(parts.slice(1).join(" ") || ""); } - fetchUserData(); - }, []); + setLoading(false); + }, [user, userLoading]); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !userId) return; + + setUploading(true); + const formData = new FormData(); + formData.append("file", file); + + try { + const res = await fetch(`/api/users/${userId}/upload-avatar`, { + method: "POST", + body: formData, + }); + + if (!res.ok) throw new Error("Upload failed"); + const data = await res.json(); + setAvatarUrl(data.url); + localStorage.setItem(`user_avatar_${userId}`, data.url); + alert("Profile picture uploaded successfully to Minio!"); + } catch (err) { + console.error(err); + alert("Failed to upload profile picture."); + } finally { + setUploading(false); + } + }; const handleSave = async () => { + if (!userId) return; setSaving(true); try { - const res = await fetch(`http://localhost:8000/api/users/${userId}`, { + const res = await fetch(`/api/users/${userId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `${firstName} ${lastName}`.trim(), target_exam: targetExam, + school: school.trim(), + state: state.trim(), }), }); if (res.ok) { + await refreshUser(); alert("Settings saved successfully!"); } else { alert("Failed to save settings: " + (await res.text())); @@ -60,117 +92,166 @@ export default function SettingsPage() { } }; - if (loading) { - return
Loading settings...
; + if (loading || userLoading) { + return
Loading settings...
; } return (

Account Settings

-

Manage your personal details and platform preferences.

+

Manage your personal details, school details, and platform preferences.

-
- +
+ {/* Profile photo upload tile */} + - Profile Details - Update your personal information. + Profile Photo + Upload photo to Minio storage. - -
-
- - setFirstName(e.target.value)} - className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" - /> -
-
- - setLastName(e.target.value)} - className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" - /> -
+ +
+ {avatarUrl ? ( + Avatar + ) : ( + + )} + {uploading && ( +
+ Uploading... +
+ )}
-
- - -
- + +
- - - Academic Preferences - Tailor the AI to your specific exam targets. - - -
- -
- -
+
+ {}} - className="accent-[#00F0FF] w-4 h-4" + type="text" + value={lastName} + onChange={(e) => setLastName(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" /> - JEE Main & Adv - +
-
-
- - -
- -
-
+
+ + setSchool(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + /> +
+
+ + setState(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + /> +
+
+ + +
+ + + + + + + Academic Preferences + Tailor the AI to your specific exam targets. + + +
+ +
+ + +
+
+ +
+
+
); diff --git a/src/app/globals.css b/src/app/globals.css index 81c049d..44e0632 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -44,23 +44,21 @@ --muted-foreground: #737373; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --border: #1f1f1f; - --input: #1f1f1f; - --ring: #D1FF4D; +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --border: #1f1f1f; + --input: #1f1f1f; + --ring: #D1FF4D; - --primary: #D1FF4D; - --primary-foreground: #0a0a0a; - --secondary: #171717; - --secondary-foreground: #ededed; - --accent: #00F0FF; - --accent-foreground: #0a0a0a; - --muted: #171717; - --muted-foreground: #a3a3a3; - } + --primary: #D1FF4D; + --primary-foreground: #0a0a0a; + --secondary: #171717; + --secondary-foreground: #ededed; + --accent: #00F0FF; + --accent-foreground: #0a0a0a; + --muted: #171717; + --muted-foreground: #a3a3a3; } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 51f5ef6..9a5a7d4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -29,6 +29,21 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning > + +