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
This commit is contained in:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -10,3 +10,4 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
celerybeat-schedule*
|
||||||
|
|||||||
Binary file not shown.
@@ -456,6 +456,8 @@ class UserUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
target_exam: Optional[models.ExamEnum] = None
|
target_exam: Optional[models.ExamEnum] = None
|
||||||
student_class: Optional[models.ClassEnum] = None
|
student_class: Optional[models.ClassEnum] = None
|
||||||
|
school: Optional[str] = None
|
||||||
|
state: Optional[str] = None
|
||||||
|
|
||||||
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||||
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
|
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
|
||||||
@@ -468,14 +470,36 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D
|
|||||||
if payload.target_exam is not None:
|
if payload.target_exam is not None:
|
||||||
user.target_exam = payload.target_exam
|
user.target_exam = payload.target_exam
|
||||||
if payload.student_class is not None:
|
if payload.student_class is not None:
|
||||||
# Note: mapping alias 'class' in User class to 'student_class'
|
|
||||||
user.student_class = payload.student_class
|
user.student_class = payload.student_class
|
||||||
|
if payload.school is not None:
|
||||||
|
user.school = payload.school
|
||||||
|
if payload.state is not None:
|
||||||
|
user.state = payload.state
|
||||||
|
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
from storage import upload_file
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
||||||
|
async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
|
||||||
|
object_name = f"avatar_{user_id}.{file_extension}"
|
||||||
|
public_url = upload_file(contents, object_name, content_type=file.content_type)
|
||||||
|
|
||||||
|
if not public_url:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to upload image to Minio.")
|
||||||
|
|
||||||
|
return {"url": public_url}
|
||||||
|
|
||||||
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||||
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
|
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
|
||||||
query = select(models.Test)
|
query = select(models.Test)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pyjwt==2.8.0
|
|||||||
celery==5.3.6
|
celery==5.3.6
|
||||||
openai==1.14.0
|
openai==1.14.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
boto3==1.34.0
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ def seed_local_database():
|
|||||||
# Ensure tables are created
|
# Ensure tables are created
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
has_data = False
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
# Prevent double seeding in local testing environment
|
# Prevent double seeding in local testing environment
|
||||||
if session.exec(select(models.Question)).first() is not None:
|
if session.exec(select(models.Question)).first() is not None:
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if has_data:
|
||||||
print("Local environment database already contains data components. Dropping all and reseeding...")
|
print("Local environment database already contains data components. Dropping all and reseeding...")
|
||||||
import sqlmodel
|
import sqlmodel
|
||||||
sqlmodel.SQLModel.metadata.drop_all(engine)
|
sqlmodel.SQLModel.metadata.drop_all(engine)
|
||||||
@@ -20,14 +24,51 @@ def seed_local_database():
|
|||||||
|
|
||||||
print("Seeding baseline content entities...")
|
print("Seeding baseline content entities...")
|
||||||
|
|
||||||
# 0. Create the baseline student user (seeded target: JEE_MAIN)
|
with Session(engine) as session:
|
||||||
|
# 0. Create the baseline student users (Free, Pro, AI Coach)
|
||||||
|
free_student_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||||
|
free_student = models.User(
|
||||||
|
id=free_student_id,
|
||||||
|
phone="8888888888",
|
||||||
|
email="free@pragativeer.com",
|
||||||
|
password_hash="mock_hash",
|
||||||
|
name="Amit Kumar (Free Tier)",
|
||||||
|
student_class=models.ClassEnum.TWELFTH,
|
||||||
|
medium=models.MediumEnum.ENGLISH,
|
||||||
|
target_exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
city="Patna",
|
||||||
|
state="Bihar",
|
||||||
|
tier=models.TierEnum.FREE,
|
||||||
|
streak_count=0,
|
||||||
|
last_active=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(free_student)
|
||||||
|
|
||||||
|
pro_student_id = UUID("22222222-2222-2222-2222-222222222222")
|
||||||
|
pro_student = models.User(
|
||||||
|
id=pro_student_id,
|
||||||
|
phone="7777777777",
|
||||||
|
email="pro@pragativeer.com",
|
||||||
|
password_hash="mock_hash",
|
||||||
|
name="Priya Singh (Pro Tier)",
|
||||||
|
student_class=models.ClassEnum.TWELFTH,
|
||||||
|
medium=models.MediumEnum.ENGLISH,
|
||||||
|
target_exam=models.ExamEnum.NEET,
|
||||||
|
city="Kota",
|
||||||
|
state="Rajasthan",
|
||||||
|
tier=models.TierEnum.PRO,
|
||||||
|
streak_count=3,
|
||||||
|
last_active=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(pro_student)
|
||||||
|
|
||||||
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
|
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
|
||||||
student_user = models.User(
|
student_user = models.User(
|
||||||
id=student_id,
|
id=student_id,
|
||||||
phone="9999999999",
|
phone="9999999999",
|
||||||
email="student@pragativeer.com",
|
email="student@pragativeer.com",
|
||||||
password_hash="mock_hash",
|
password_hash="mock_hash",
|
||||||
name="Rahul Sharma",
|
name="Rahul Sharma (AI Coach Tier)",
|
||||||
student_class=models.ClassEnum.TWELFTH,
|
student_class=models.ClassEnum.TWELFTH,
|
||||||
medium=models.MediumEnum.ENGLISH,
|
medium=models.MediumEnum.ENGLISH,
|
||||||
target_exam=models.ExamEnum.JEE_MAIN,
|
target_exam=models.ExamEnum.JEE_MAIN,
|
||||||
|
|||||||
91
backend/storage.py
Normal file
91
backend/storage.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
import json
|
||||||
|
|
||||||
|
logger = logging.getLogger("storage")
|
||||||
|
|
||||||
|
# Read settings from environment
|
||||||
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||||
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minio_admin")
|
||||||
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minio_secure_password_2026")
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "pragativeer-uploads")
|
||||||
|
|
||||||
|
# Initialize S3/Minio client
|
||||||
|
# Since backend runs inside Docker, endpoint_url must point to 'http://minio:9000'
|
||||||
|
# However, from the client browser, it will be reached via 'http://localhost:9000'
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=f"http://{MINIO_ENDPOINT}",
|
||||||
|
aws_access_key_id=MINIO_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=MINIO_SECRET_KEY,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
region_name="us-east-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_bucket_exists():
|
||||||
|
try:
|
||||||
|
# Check if bucket exists
|
||||||
|
s3_client.head_bucket(Bucket=MINIO_BUCKET)
|
||||||
|
logger.info(f"Minio bucket '{MINIO_BUCKET}' already exists.")
|
||||||
|
except ClientError as e:
|
||||||
|
error_code = e.response["Error"]["Code"]
|
||||||
|
if error_code == "404":
|
||||||
|
try:
|
||||||
|
# Create bucket
|
||||||
|
s3_client.create_bucket(Bucket=MINIO_BUCKET)
|
||||||
|
logger.info(f"Successfully created Minio bucket '{MINIO_BUCKET}'.")
|
||||||
|
|
||||||
|
# Set public read policy for direct browser access
|
||||||
|
bucket_policy = {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PublicReadGetObject",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [f"arn:aws:s3:::{MINIO_BUCKET}/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
s3_client.put_bucket_policy(
|
||||||
|
Bucket=MINIO_BUCKET,
|
||||||
|
Policy=json.dumps(bucket_policy)
|
||||||
|
)
|
||||||
|
logger.info(f"Public read policy applied to Minio bucket '{MINIO_BUCKET}'.")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Failed to create Minio bucket: {ex}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Error checking Minio bucket: {e}")
|
||||||
|
|
||||||
|
def upload_file(file_content: bytes, object_name: str, content_type: str = "application/octet-stream") -> 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""
|
||||||
@@ -44,6 +44,10 @@ services:
|
|||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- ENV=development
|
- ENV=development
|
||||||
|
- MINIO_ENDPOINT=minio:9000
|
||||||
|
- MINIO_ACCESS_KEY=minio_admin
|
||||||
|
- MINIO_SECRET_KEY=minio_secure_password_2026
|
||||||
|
- MINIO_BUCKET=pragativeer-uploads
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -63,6 +67,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- 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:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -107,9 +115,25 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- pragativeer_network
|
- 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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
redisdata:
|
||||||
|
miniodata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pragativeer_network:
|
pragativeer_network:
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://backend:8000/api/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||||
@@ -11,20 +11,22 @@ import { ProgressShare } from "@/components/AI/ProgressShare"
|
|||||||
import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard"
|
import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2 } from "lucide-react"
|
import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2, Lock } from "lucide-react"
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
// Hardcode mock UUID for demo fetching
|
import Link from "next/link"
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
|
||||||
|
|
||||||
export default function AICoachDashboard() {
|
export default function AICoachDashboard() {
|
||||||
const [mistakes, setMistakes] = useState<MistakePayload[]>([])
|
const [mistakes, setMistakes] = useState<MistakePayload[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
const fetchMistakes = async () => {
|
const fetchMistakes = async () => {
|
||||||
|
if (!userId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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")
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setMistakes(data)
|
setMistakes(data)
|
||||||
@@ -38,11 +40,13 @@ export default function AICoachDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userLoading && userId) {
|
||||||
fetchMistakes()
|
fetchMistakes()
|
||||||
}, [])
|
}
|
||||||
|
}, [userId, userLoading])
|
||||||
|
|
||||||
const submitReview = async (mistakeId: string, selectedAnswer: string) => {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer })
|
body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer })
|
||||||
@@ -61,6 +65,33 @@ export default function AICoachDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userLoading) {
|
||||||
|
return <div className="p-8 text-center text-muted-foreground animate-pulse">Loading AI Coach...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccessTo("ai-mistake-tracking")) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto text-center py-20 px-8 space-y-8 animate-in fade-in duration-500">
|
||||||
|
<div className="size-20 rounded-full bg-[#00F0FF]/10 flex items-center justify-center mx-auto text-[#00F0FF] border border-[#00F0FF]/20 shadow-[0_0_20px_rgba(0,240,255,0.1)]">
|
||||||
|
<BrainCircuit size={40} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight">AI Coach Workspace</h1>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-xl mx-auto leading-relaxed">
|
||||||
|
Mistake tracking, Spaced Repetition (SM-2), AI Tutor Chats, and Readiness scores are premium features reserved for **AI Coach** subscribers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link href="/upgrade">
|
||||||
|
<Button size="lg" className="rounded-xl px-8 font-bold bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 shadow-[0_0_25px_-5px_#00F0FF]">
|
||||||
|
Upgrade to AI Coach Plan
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500">
|
<div className="p-8 max-w-6xl mx-auto space-y-10 animate-in fade-in duration-500">
|
||||||
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import Link from "next/link";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
interface TestData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,27 +23,31 @@ export default function CBTPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [targetExam, setTargetExam] = useState("");
|
const [targetExam, setTargetExam] = useState("");
|
||||||
const [attempts, setAttempts] = useState<any[]>([]);
|
const [attempts, setAttempts] = useState<any[]>([]);
|
||||||
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||||
|
|
||||||
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
|
|
||||||
async function loadCBTExams() {
|
async function loadCBTExams() {
|
||||||
try {
|
try {
|
||||||
// 1. Fetch user target exam
|
// 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");
|
if (!userRes.ok) throw new Error("Failed to load user target exam");
|
||||||
const userData = await userRes.json();
|
const userData = await userRes.json();
|
||||||
const pattern = userData.target_exam;
|
const pattern = userData.target_exam;
|
||||||
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main");
|
||||||
|
|
||||||
// 2. Fetch tests filtered by exam
|
// 2. Fetch tests filtered by exam
|
||||||
const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`);
|
const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`);
|
||||||
if (!testRes.ok) throw new Error("Failed to fetch tests");
|
if (testRes.ok) {
|
||||||
const testsData = await testRes.json();
|
const testsData = await testRes.json();
|
||||||
setExams(testsData);
|
setExams(testsData);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Fetch user attempts
|
// 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) {
|
if (attemptsRes.ok) {
|
||||||
const attemptsData = await attemptsRes.json();
|
const attemptsData = await attemptsRes.json();
|
||||||
setAttempts(attemptsData);
|
setAttempts(attemptsData);
|
||||||
@@ -54,7 +59,7 @@ export default function CBTPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadCBTExams();
|
loadCBTExams();
|
||||||
}, []);
|
}, [userId, userLoading]);
|
||||||
|
|
||||||
const filteredExams = exams.filter(exam =>
|
const filteredExams = exams.filter(exam =>
|
||||||
exam.title.toLowerCase().includes(search.toLowerCase())
|
exam.title.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -109,19 +114,24 @@ export default function CBTPage() {
|
|||||||
{filteredExams.map((exam) => {
|
{filteredExams.map((exam) => {
|
||||||
const attempt = attemptsByTest[exam.id];
|
const attempt = attemptsByTest[exam.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
const isLocked = !hasAccessTo("weekly-test-series");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''}`}>
|
<Card key={exam.id} className={`hover:border-[#00F0FF]/50 hover:shadow-[0_0_15px_-5px_#00F0FF] transition-all group ${isAttempted ? 'border-primary/20' : ''} ${isLocked ? 'opacity-80' : ''}`}>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">
|
||||||
{exam.type} Test
|
{exam.type} Test
|
||||||
</Badge>
|
</Badge>
|
||||||
{isAttempted && (
|
{isLocked ? (
|
||||||
|
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20 flex items-center gap-1">
|
||||||
|
<Lock size={12} /> Premium
|
||||||
|
</Badge>
|
||||||
|
) : isAttempted ? (
|
||||||
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="secondary" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
Attempted
|
Attempted
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">
|
<h3 className="font-bold text-lg leading-tight mb-2 group-hover:text-[#00F0FF] transition-colors">
|
||||||
{exam.title}
|
{exam.title}
|
||||||
@@ -130,11 +140,20 @@ export default function CBTPage() {
|
|||||||
<div className="font-medium text-foreground">Available Now</div>
|
<div className="font-medium text-foreground">Available Now</div>
|
||||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isLocked ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowUpgradeModal(true)}
|
||||||
|
className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground border border-dashed border-border hover:border-[#00F0FF]/50 hover:text-foreground font-bold"
|
||||||
|
>
|
||||||
|
<Lock size={16} /> Unlock Test
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Link href={`/cbt/${exam.id}`}>
|
<Link href={`/cbt/${exam.id}`}>
|
||||||
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}>
|
<Button className={`w-full gap-2 rounded-xl transition-colors font-bold ${isAttempted ? 'bg-secondary border border-border text-foreground' : 'bg-secondary text-foreground hover:bg-[#00F0FF] hover:text-background'}`}>
|
||||||
<PlayCircle size={16} /> {isAttempted ? 'Retake Exam' : 'Start Exam'}
|
<PlayCircle size={16} /> {isAttempted ? 'Retake Exam' : 'Start Exam'}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)})}
|
)})}
|
||||||
@@ -185,6 +204,30 @@ export default function CBTPage() {
|
|||||||
)})}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showUpgradeModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[450px] p-8 text-center space-y-6 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="size-16 rounded-full bg-red-500/10 flex items-center justify-center mx-auto text-red-500">
|
||||||
|
<Lock size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold">Upgrade to Premium Plan</h3>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
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!
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<Button variant="outline" className="flex-1 rounded-xl" onClick={() => setShowUpgradeModal(false)}>
|
||||||
|
Maybe Later
|
||||||
|
</Button>
|
||||||
|
<Link href="/upgrade" className="flex-1">
|
||||||
|
<Button className="w-full rounded-xl bg-primary text-primary-foreground font-bold shadow-[0_0_15px_-3px_var(--color-primary)]">
|
||||||
|
View Plans
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ReactNode, useState, useEffect, useRef } from "react"
|
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 Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
@@ -10,12 +10,17 @@ import { ThemeToggle } from "@/components/ThemeToggle"
|
|||||||
import { TierBadge } from "@/components/TierBadge"
|
import { TierBadge } from "@/components/TierBadge"
|
||||||
import { StreakCounter } from "@/components/StreakCounter"
|
import { StreakCounter } from "@/components/StreakCounter"
|
||||||
import { AITutorChat } from "@/components/AI/AITutorChat"
|
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 }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { userId, user, switchUser, hasAccessTo, loading } = useActiveUser();
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -27,6 +32,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-primary/20">
|
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-primary/20">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -51,7 +59,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 px-4 space-y-1.5 mt-4 overflow-y-auto">
|
<nav className="flex-1 px-4 space-y-1.5 mt-4 overflow-y-auto">
|
||||||
<NavItem href="/dashboard" icon={<LayoutDashboard size={18} />} label="Overview" active={pathname === "/dashboard"} />
|
<NavItem href="/dashboard" icon={<LayoutDashboard size={18} />} label="Overview" active={pathname === "/dashboard"} />
|
||||||
<NavItem href="/dashboard/cbt" icon={<Activity size={18} />} label="Exams (CBT)" active={pathname.startsWith("/dashboard/cbt")} />
|
<NavItem
|
||||||
|
href="/dashboard/cbt"
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="Exams (CBT)"
|
||||||
|
active={pathname.startsWith("/dashboard/cbt")}
|
||||||
|
isLocked={!hasAccessTo("weekly-test-series")}
|
||||||
|
/>
|
||||||
<NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} />
|
<NavItem href="/dashboard/dpp" icon={<FileText size={18} />} label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} />
|
||||||
<NavItem href="/dashboard/materials" icon={<BookOpen size={18} />} label="Study Materials" active={pathname.startsWith("/dashboard/materials")} />
|
<NavItem href="/dashboard/materials" icon={<BookOpen size={18} />} label="Study Materials" active={pathname.startsWith("/dashboard/materials")} />
|
||||||
|
|
||||||
@@ -65,17 +79,30 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="pt-4 pb-2">
|
<div className="pt-4 pb-2">
|
||||||
<p className="px-3 text-xs font-bold uppercase tracking-widest text-muted-foreground">AI Intelligence</p>
|
<p className="px-3 text-xs font-bold uppercase tracking-widest text-muted-foreground">AI Intelligence</p>
|
||||||
</div>
|
</div>
|
||||||
<NavItem href="/dashboard/analytics" icon={<LineChart size={18} />} label="Performance Analytics" active={pathname.startsWith("/dashboard/analytics")} />
|
<NavItem
|
||||||
<NavItem href="/dashboard/rank-predictor" icon={<Trophy size={18} />} label="Rank Predictor" active={pathname.startsWith("/dashboard/rank-predictor")} />
|
href="/dashboard/analytics"
|
||||||
|
icon={<LineChart size={18} />}
|
||||||
|
label="Performance Analytics"
|
||||||
|
active={pathname.startsWith("/dashboard/analytics")}
|
||||||
|
isLocked={!hasAccessTo("ai-mistake-tracking")}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/rank-predictor"
|
||||||
|
icon={<Trophy size={18} />}
|
||||||
|
label="Rank Predictor"
|
||||||
|
active={pathname.startsWith("/dashboard/rank-predictor")}
|
||||||
|
isLocked={!hasAccessTo("ai-mistake-tracking")}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/ai-coach"
|
||||||
|
icon={<BrainCircuitIcon />}
|
||||||
|
label="AI Coach"
|
||||||
|
active={pathname.startsWith("/dashboard/ai-coach")}
|
||||||
|
isLocked={!hasAccessTo("ai-mistake-tracking")}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border space-y-2 bg-secondary/30">
|
<div className="p-4 border-t border-border space-y-2 bg-secondary/30">
|
||||||
<div className="flex justify-end mb-2 px-1">
|
|
||||||
<Link href="/dashboard/streaks" className="w-full text-right hover:opacity-80 transition-opacity">
|
|
||||||
<StreakCounter streak={12} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings"
|
href="/dashboard/settings"
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 border ${
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 border ${
|
||||||
@@ -90,19 +117,22 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
{/* Functional User Dropdown */}
|
{/* Functional User Dropdown */}
|
||||||
<div className="relative mt-2" ref={dropdownRef}>
|
<div className="relative mt-2" ref={dropdownRef}>
|
||||||
{dropdownOpen && (
|
{dropdownOpen && user && (
|
||||||
<div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-lg shadow-black/10 overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200 z-50">
|
<div className="absolute bottom-full left-0 w-full mb-2 bg-background border border-border rounded-xl shadow-lg shadow-black/10 overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200 z-50">
|
||||||
<div className="px-4 py-3 border-b border-border">
|
<div className="px-4 py-3 border-b border-border">
|
||||||
<p className="text-sm font-bold text-foreground">Demo Student</p>
|
<p className="text-sm font-bold text-foreground truncate">{user.name}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">demo@pragativeer.com</p>
|
<p className="text-xs text-muted-foreground truncate">{user.email || user.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<Link href="/dashboard/settings" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
<Link href="/dashboard/settings" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
||||||
<User size={16} /> Edit Profile
|
<User size={16} /> Edit Profile
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded-lg transition-colors font-medium mt-1">
|
<button
|
||||||
<LogOut size={16} /> Log out
|
onClick={() => switchUser(DEMO_USERS.AI_COACH.id)}
|
||||||
</Link>
|
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Reset Demo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,50 +141,111 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 text-left">
|
||||||
<div className="h-8 w-8 rounded-full bg-[#00F0FF]/20 text-[#00F0FF] flex items-center justify-center font-bold text-sm shadow-[0_0_10px_-2px_#00F0FF]">
|
<div className="h-8 w-8 rounded-full bg-[#00F0FF]/25 text-[#00F0FF] flex items-center justify-center font-bold text-sm shadow-[0_0_10px_-2px_#00F0FF] shrink-0">
|
||||||
DS
|
{userInitials}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold leading-none truncate">Demo Student</p>
|
<p className="text-sm font-bold leading-none truncate">{user ? user.name.split(" ")[0] : "Loading..."}</p>
|
||||||
<div className="mt-1.5"><TierBadge tier="AI Coach" /></div>
|
<div className="mt-1"><TierBadge tier={user ? (user.tier === "Al_Coach" ? "AI Coach" : user.tier) : "Free"} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronUp size={16} className={`text-muted-foreground transition-transform ${dropdownOpen ? "rotate-180" : ""}`} />
|
<ChevronUp size={16} className={`text-muted-foreground transition-transform shrink-0 ${dropdownOpen ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col h-screen overflow-hidden relative">
|
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||||
<header className="absolute top-6 right-8 z-10 flex items-center gap-3 bg-background/80 backdrop-blur-sm p-1.5 rounded-2xl border border-border/50 shadow-sm">
|
{/* Top Header Static Bar */}
|
||||||
|
<header className="h-16 border-b border-border bg-background/95 backdrop-blur-md flex items-center justify-between px-8 shrink-0 z-40">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="font-bold text-lg hidden sm:block">
|
||||||
|
{user ? `Welcome back, ${user.name.split(" ")[0]}! 👋` : "Welcome back!"}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<Badge variant="outline" className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 font-bold tracking-wide uppercase px-2.5 py-0.5 animate-pulse">
|
||||||
|
{targetExamLabel} Target
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Streak count moved to top header */}
|
||||||
|
{user && (
|
||||||
|
<Link href="/dashboard/streaks" className="hover:opacity-85 transition-opacity flex items-center gap-1.5 bg-secondary/50 border border-border px-3 py-1 rounded-xl">
|
||||||
|
<StreakCounter streak={user.streak_count} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profile switcher dropdown for testing */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground font-bold hidden lg:inline">Demo Switcher:</span>
|
||||||
|
<select
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => switchUser(e.target.value)}
|
||||||
|
className="bg-secondary border border-border text-foreground px-2.5 py-1.5 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none cursor-pointer hover:bg-secondary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<option value={DEMO_USERS.AI_COACH.id}>Rahul (AI Coach)</option>
|
||||||
|
<option value={DEMO_USERS.PRO.id}>Priya (Pro Plan)</option>
|
||||||
|
<option value={DEMO_USERS.FREE.id}>Amit (Free Plan)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-l border-border pl-4">
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-y-auto">
|
|
||||||
|
{/* Scrollable page body */}
|
||||||
|
<main className="flex-1 overflow-y-auto relative bg-background/50">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global AI Tutor Chat */}
|
{/* Global AI Tutor Chat */}
|
||||||
<AITutorChat />
|
{hasAccessTo("ai-tutor-chat") && <AITutorChat />}
|
||||||
|
|
||||||
|
{/* Onboarding Motivation Popup */}
|
||||||
|
<StreakOnboardingModal />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={isLocked ? "#" : href}
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 ${
|
className={`flex items-center justify-between px-3 py-2.5 rounded-xl transition-all duration-300 ${
|
||||||
active
|
active
|
||||||
? "bg-secondary text-foreground font-medium border border-border"
|
? "bg-secondary text-foreground font-semibold border border-border"
|
||||||
|
: isLocked
|
||||||
|
? "text-muted-foreground/60 cursor-not-allowed border border-transparent"
|
||||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground border border-transparent"
|
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{icon}
|
{icon}
|
||||||
<span className="text-sm">{label}</span>
|
<span className="text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
{isLocked && <Lock size={12} className="text-muted-foreground/50" />}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BrainCircuitIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-brain-circuit">
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
|
||||||
|
<path d="M18 8a3 3 0 0 0-3-3h-1"/>
|
||||||
|
<path d="M9 5H8a3 3 0 0 0-3 3v1"/>
|
||||||
|
<path d="M5 14v1a3 3 0 0 0 3 3h1"/>
|
||||||
|
<path d="M14 18h1a3 3 0 0 0 3-3v-1"/>
|
||||||
|
<path d="M12 18v2"/>
|
||||||
|
<path d="M12 4V2"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,85 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||||
|
import { User as UserIcon, Upload, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [targetExam, setTargetExam] = useState<"JEE_Main" | "NEET">("JEE_Main");
|
const { userId, user, refreshUser, loading: userLoading } = useActiveUser();
|
||||||
const [targetYear, setTargetYear] = useState("2026");
|
const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET">("JEE_Main");
|
||||||
const [firstName, setFirstName] = useState("Rahul");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("Sharma");
|
const [lastName, setLastName] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [school, setSchool] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [state, setState] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
|
||||||
const userId = "b0e17378-864d-439e-8806-708dc48a891b";
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUserData() {
|
if (userLoading || !user) return;
|
||||||
try {
|
|
||||||
const res = await fetch(`http://localhost:8000/api/users/${userId}`);
|
setTargetExam(user.target_exam);
|
||||||
if (res.ok) {
|
setSchool(user.school || "");
|
||||||
const data = await res.json();
|
setState(user.state || "");
|
||||||
setTargetExam(data.target_exam === "NEET" ? "NEET" : "JEE_Main");
|
setEmail(user.email || "");
|
||||||
if (data.name) {
|
setPhone(user.phone || "");
|
||||||
const parts = data.name.split(" ");
|
setAvatarUrl(localStorage.getItem(`user_avatar_${user.id}`) || null);
|
||||||
|
|
||||||
|
if (user.name) {
|
||||||
|
const parts = user.name.split(" ");
|
||||||
setFirstName(parts[0] || "");
|
setFirstName(parts[0] || "");
|
||||||
setLastName(parts.slice(1).join(" ") || "");
|
setLastName(parts.slice(1).join(" ") || "");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load user profile:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}, [user, userLoading]);
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
fetchUserData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (!userId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/users/${userId}`, {
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: `${firstName} ${lastName}`.trim(),
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
target_exam: targetExam,
|
target_exam: targetExam,
|
||||||
|
school: school.trim(),
|
||||||
|
state: state.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
await refreshUser();
|
||||||
alert("Settings saved successfully!");
|
alert("Settings saved successfully!");
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to save settings: " + (await res.text()));
|
alert("Failed to save settings: " + (await res.text()));
|
||||||
@@ -60,22 +92,61 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading || userLoading) {
|
||||||
return <div className="p-8">Loading settings...</div>;
|
return <div className="p-8 text-center text-muted-foreground animate-pulse">Loading settings...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl mx-auto">
|
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Account Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Account Settings</h1>
|
||||||
<p className="text-muted-foreground mt-1 font-medium">Manage your personal details and platform preferences.</p>
|
<p className="text-muted-foreground mt-1 font-medium">Manage your personal details, school details, and platform preferences.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{/* Profile photo upload tile */}
|
||||||
|
<Card className="border-border bg-background shadow-sm md:col-span-1 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile Photo</CardTitle>
|
||||||
|
<CardDescription>Upload photo to Minio storage.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="relative size-32 rounded-full border border-border bg-secondary/50 flex items-center justify-center overflow-hidden">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="Avatar" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<UserIcon className="size-16 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center text-white text-xs font-bold">
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="w-full">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" className="w-full gap-2 rounded-xl border-dashed" asChild>
|
||||||
|
<span>
|
||||||
|
<Upload size={16} /> {avatarUrl ? "Change Photo" : "Upload Photo"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Profile settings fields tile */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
<Card className="border-border bg-background shadow-sm">
|
<Card className="border-border bg-background shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Details</CardTitle>
|
<CardTitle>Profile Details</CardTitle>
|
||||||
<CardDescription>Update your personal information.</CardDescription>
|
<CardDescription>Update your personal and school information.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -99,10 +170,30 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold">Email Address</label>
|
<label className="text-sm font-bold">School Name</label>
|
||||||
<input type="email" defaultValue="student@pragativeer.com" disabled className="w-full bg-secondary border border-border rounded-lg px-4 py-2 text-sm text-muted-foreground cursor-not-allowed" />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={school}
|
||||||
|
placeholder="Enter your school name"
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2">
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold">State</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state}
|
||||||
|
placeholder="Enter your state (e.g. Rajasthan, Delhi)"
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold">Email Address</label>
|
||||||
|
<input type="email" value={email || "Not Provided"} disabled className="w-full bg-secondary border border-border rounded-lg px-4 py-2 text-sm text-muted-foreground cursor-not-allowed" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2 rounded-xl px-6">
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -155,23 +246,13 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2 rounded-xl px-6">
|
||||||
<label className="text-sm font-bold">Target Year</label>
|
|
||||||
<select
|
|
||||||
value={targetYear}
|
|
||||||
onChange={(e) => setTargetYear(e.target.value)}
|
|
||||||
className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF] appearance-none font-medium"
|
|
||||||
>
|
|
||||||
<option>2026</option>
|
|
||||||
<option>2027</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSave} disabled={saving} className="bg-foreground text-background hover:bg-foreground/90 font-bold mt-2">
|
|
||||||
{saving ? "Saving..." : "Save Preferences"}
|
{saving ? "Saving..." : "Save Preferences"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,7 @@
|
|||||||
--muted-foreground: #737373;
|
--muted-foreground: #737373;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
--border: #1f1f1f;
|
--border: #1f1f1f;
|
||||||
@@ -60,7 +59,6 @@
|
|||||||
--accent-foreground: #0a0a0a;
|
--accent-foreground: #0a0a0a;
|
||||||
--muted: #171717;
|
--muted: #171717;
|
||||||
--muted-foreground: #a3a3a3;
|
--muted-foreground: #a3a3a3;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
import { Activity, Clock, Flame, AlertTriangle, RefreshCw } from "lucide-react"
|
import { Activity, Clock, Flame, AlertTriangle, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
// Hardcoded mock UUIDs for this sprint
|
// Hardcoded mock UUIDs for this sprint
|
||||||
const PARENT_ID = "parent-123"
|
const PARENT_ID = "parent-123"
|
||||||
const STUDENT_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
|
||||||
|
|
||||||
const DICTIONARY = {
|
const DICTIONARY = {
|
||||||
en: {
|
en: {
|
||||||
@@ -49,6 +50,7 @@ const DICTIONARY = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ParentDashboard() {
|
export default function ParentDashboard() {
|
||||||
|
const { userId, loading: userLoading } = useActiveUser()
|
||||||
const [lang, setLang] = useState<'en' | 'hi'>('en')
|
const [lang, setLang] = useState<'en' | 'hi'>('en')
|
||||||
const [data, setData] = useState<any>(null)
|
const [data, setData] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -56,9 +58,10 @@ export default function ParentDashboard() {
|
|||||||
const t = DICTIONARY[lang]
|
const t = DICTIONARY[lang]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
async function fetchDashboard() {
|
async function fetchDashboard() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/parent/${PARENT_ID}/dashboard/${STUDENT_ID}`)
|
const res = await fetch(`/api/parent/${PARENT_ID}/dashboard/${userId}`)
|
||||||
if (!res.ok) throw new Error("Failed to fetch")
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
const payload = await res.json()
|
const payload = await res.json()
|
||||||
setData(payload)
|
setData(payload)
|
||||||
@@ -89,7 +92,7 @@ export default function ParentDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchDashboard()
|
fetchDashboard()
|
||||||
}, [])
|
}, [userId, userLoading])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter }
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Check, X, ShieldCheck, Loader2 } from "lucide-react"
|
import { Check, X, ShieldCheck, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
const WEBHOOK_SECRET = "local_webhook_secret"
|
const WEBHOOK_SECRET = "local_webhook_secret"
|
||||||
|
|
||||||
export default function UpgradePage() {
|
export default function UpgradePage() {
|
||||||
|
const { userId, loading: userLoading, refreshUser } = useActiveUser()
|
||||||
const [loadingTier, setLoadingTier] = useState<string | null>(null)
|
const [loadingTier, setLoadingTier] = useState<string | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [orderData, setOrderData] = useState<any>(null)
|
const [orderData, setOrderData] = useState<any>(null)
|
||||||
@@ -16,9 +18,10 @@ export default function UpgradePage() {
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
const handleUpgrade = async (tier: string) => {
|
const handleUpgrade = async (tier: string) => {
|
||||||
|
if (userLoading || !userId) return
|
||||||
setLoadingTier(tier)
|
setLoadingTier(tier)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/billing/create-order?user_id=${USER_ID}&plan_tier=${tier}`, {
|
const res = await fetch(`/api/billing/create-order?user_id=${userId}&plan_tier=${tier}`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Order creation failed")
|
if (!res.ok) throw new Error("Order creation failed")
|
||||||
@@ -47,6 +50,7 @@ export default function UpgradePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const simulatePayment = async () => {
|
const simulatePayment = async () => {
|
||||||
|
if (!userId) return
|
||||||
setProcessing(true)
|
setProcessing(true)
|
||||||
try {
|
try {
|
||||||
const payloadObj = {
|
const payloadObj = {
|
||||||
@@ -57,7 +61,7 @@ export default function UpgradePage() {
|
|||||||
id: `pay_mock_${Date.now()}`,
|
id: `pay_mock_${Date.now()}`,
|
||||||
amount: orderData.amount,
|
amount: orderData.amount,
|
||||||
notes: {
|
notes: {
|
||||||
user_id: USER_ID,
|
user_id: userId,
|
||||||
tier_requested: orderData.tier
|
tier_requested: orderData.tier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +72,7 @@ export default function UpgradePage() {
|
|||||||
const payloadStr = JSON.stringify(payloadObj)
|
const payloadStr = JSON.stringify(payloadObj)
|
||||||
const signature = await generateMockSignature(payloadStr)
|
const signature = await generateMockSignature(payloadStr)
|
||||||
|
|
||||||
const res = await fetch(`http://localhost:8000/api/billing/webhook/razorpay`, {
|
const res = await fetch(`/api/billing/webhook/razorpay`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -80,6 +84,7 @@ export default function UpgradePage() {
|
|||||||
if (!res.ok) throw new Error("Webhook failed")
|
if (!res.ok) throw new Error("Webhook failed")
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
await refreshUser()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = "/dashboard"
|
window.location.href = "/dashboard"
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { useEffect, useState } from "react"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { GraduationCap, MapPin, RefreshCw } from "lucide-react"
|
import { GraduationCap, MapPin, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
export function CollegePredictorCard() {
|
export function CollegePredictorCard() {
|
||||||
const [data, setData] = useState<any>(null)
|
const [data, setData] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { userId, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
|
|
||||||
async function fetchReadiness() {
|
async function fetchReadiness() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
const res = await fetch(`/api/ai-coach/readiness/${userId}`)
|
||||||
if (!res.ok) throw new Error("Failed to fetch")
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setData(json)
|
setData(json)
|
||||||
@@ -28,7 +31,7 @@ export function CollegePredictorCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchReadiness()
|
fetchReadiness()
|
||||||
}, [])
|
}, [userId, userLoading])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { useEffect, useState } from "react"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Activity, RefreshCw } from "lucide-react"
|
import { Activity, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
export function ReadinessGauge() {
|
export function ReadinessGauge() {
|
||||||
const [data, setData] = useState<any>(null)
|
const [data, setData] = useState<any>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { userId, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
|
|
||||||
async function fetchReadiness() {
|
async function fetchReadiness() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
|
const res = await fetch(`/api/ai-coach/readiness/${userId}`)
|
||||||
if (!res.ok) throw new Error("Failed to fetch")
|
if (!res.ok) throw new Error("Failed to fetch")
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setData(json)
|
setData(json)
|
||||||
@@ -34,7 +37,7 @@ export function ReadinessGauge() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchReadiness()
|
fetchReadiness()
|
||||||
}, [])
|
}, [userId, userLoading])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react"
|
import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
export function ReferralCard() {
|
export function ReferralCard() {
|
||||||
|
const { userId, loading: userLoading } = useActiveUser();
|
||||||
const [data, setData] = useState<any>(null)
|
const [data, setData] = useState<any>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
async function fetchReferral() {
|
async function fetchReferral() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/growth/referral/${USER_ID}`)
|
const res = await fetch(`/api/growth/referral/${userId}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setData(json)
|
setData(json)
|
||||||
@@ -24,9 +26,9 @@ export function ReferralCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchReferral()
|
fetchReferral()
|
||||||
}, [])
|
}, [userId, userLoading])
|
||||||
|
|
||||||
const link = data?.referral_link || "https://pragativeer.com/join/demo"
|
const link = data?.referral_link || `https://pragativeer.com/join/${userId || "demo"}`
|
||||||
const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free."
|
const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free."
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react"
|
import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
export function StudyPlanCard() {
|
export function StudyPlanCard() {
|
||||||
const [tasks, setTasks] = useState<any[]>([])
|
const [tasks, setTasks] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { userId, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading || !userId) return;
|
||||||
|
|
||||||
async function fetchPlan() {
|
async function fetchPlan() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/ai-coach/study-plan/today?user_id=${USER_ID}`)
|
const res = await fetch(`/api/ai-coach/study-plan/today?user_id=${userId}`)
|
||||||
if (!res.ok) throw new Error("No plan")
|
if (!res.ok) throw new Error("No plan")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setTasks(data.tasks || [])
|
setTasks(data.tasks || [])
|
||||||
@@ -31,7 +34,7 @@ export function StudyPlanCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchPlan()
|
fetchPlan()
|
||||||
}, [])
|
}, [userId, userLoading])
|
||||||
|
|
||||||
const toggleTask = (idx: number, newStatus: string) => {
|
const toggleTask = (idx: number, newStatus: string) => {
|
||||||
setTasks(prev => {
|
setTasks(prev => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import remarkMath from "remark-math"
|
|||||||
import rehypeKatex from "rehype-katex"
|
import rehypeKatex from "rehype-katex"
|
||||||
import "katex/dist/katex.min.css"
|
import "katex/dist/katex.min.css"
|
||||||
|
|
||||||
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
@@ -25,6 +25,8 @@ export function TutorChat() {
|
|||||||
const [streaming, setStreaming] = useState(false)
|
const [streaming, setStreaming] = useState(false)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { userId, loading: userLoading } = useActiveUser();
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ export function TutorChat() {
|
|||||||
|
|
||||||
const handleSend = async (e?: React.FormEvent) => {
|
const handleSend = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!input.trim() || streaming) return
|
if (!input.trim() || streaming || userLoading || !userId) return
|
||||||
|
|
||||||
const userMsg = input.trim()
|
const userMsg = input.trim()
|
||||||
setInput("")
|
setInput("")
|
||||||
@@ -46,10 +48,10 @@ export function TutorChat() {
|
|||||||
setStreaming(true)
|
setStreaming(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:8000/api/ai-coach/chat/stream", {
|
const res = await fetch("/api/ai-coach/chat/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ user_id: USER_ID, message: userMsg })
|
body: JSON.stringify({ user_id: userId, message: userMsg })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
92
src/components/Dashboard/StreakOnboardingModal.tsx
Normal file
92
src/components/Dashboard/StreakOnboardingModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Flame, Trophy, Award, Sparkles } from "lucide-react";
|
||||||
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||||
|
|
||||||
|
export function StreakOnboardingModal() {
|
||||||
|
const { user, loading } = useActiveUser();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !user) return;
|
||||||
|
|
||||||
|
// Check sessionStorage
|
||||||
|
const hasSeen = sessionStorage.getItem(`hasSeenStreakPopup_${user.id}`);
|
||||||
|
if (!hasSeen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [user, loading]);
|
||||||
|
|
||||||
|
if (!isOpen || !user) return null;
|
||||||
|
|
||||||
|
const streak = user.streak_count;
|
||||||
|
|
||||||
|
// Streak-based wishes and titles
|
||||||
|
let wishTitle = "Welcome to PragatiVeer! 🌟";
|
||||||
|
let wishMessage = "Prepare yourself to conquer JEE/NEET. Your daily practice starts here!";
|
||||||
|
let iconTheme = "text-[#00F0FF] bg-[#00F0FF]/10";
|
||||||
|
let iconComponent = <Sparkles className="size-10" />;
|
||||||
|
|
||||||
|
if (streak === 0) {
|
||||||
|
wishTitle = "Start Your Streak Today! 🚀";
|
||||||
|
wishMessage = "Today is Day 1 of your journey. Establish your streak now and build the consistency needed for IIT/AIIMS!";
|
||||||
|
iconTheme = "text-blue-500 bg-blue-500/10";
|
||||||
|
iconComponent = <Sparkles className="size-10" />;
|
||||||
|
} else if (streak < 4) {
|
||||||
|
wishTitle = "Keep the Fire Burning! 🔥";
|
||||||
|
wishMessage = `Great job! You have maintained a ${streak}-day study streak. Keep practicing daily to build solid muscle memory!`;
|
||||||
|
iconTheme = "text-orange-500 bg-orange-500/10 animate-bounce";
|
||||||
|
iconComponent = <Flame className="size-10 fill-current" />;
|
||||||
|
} else {
|
||||||
|
wishTitle = "Unstoppable Dedication! ⚡";
|
||||||
|
wishMessage = `Incredible! A ${streak}-day streak shows true championship discipline. You are outperforming the competition. Let's make today count!`;
|
||||||
|
iconTheme = "text-[#D1FF4D] bg-[#D1FF4D]/10 animate-pulse";
|
||||||
|
iconComponent = <Trophy className="size-10" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
sessionStorage.setItem(`hasSeenStreakPopup_${user.id}`, "true");
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-md animate-in fade-in duration-300">
|
||||||
|
<div className="bg-background border border-border shadow-[0_0_50px_-12px_rgba(0,240,255,0.2)] rounded-3xl w-full max-w-lg p-8 text-center space-y-6 relative overflow-hidden animate-in zoom-in-95 slide-in-from-bottom-4 duration-300">
|
||||||
|
|
||||||
|
{/* Animated Background Gradients */}
|
||||||
|
<div className="absolute -right-20 -top-20 size-48 bg-[#00F0FF]/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute -left-20 -bottom-20 size-48 bg-primary/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Dynamic Icon Badge */}
|
||||||
|
<div className={`size-20 rounded-2xl ${iconTheme} flex items-center justify-center mx-auto border border-current/20 shadow-[0_0_15px_rgba(0,0,0,0.1)]`}>
|
||||||
|
{iconComponent}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{streak > 0 && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-xs font-extrabold rounded-full tracking-wider uppercase">
|
||||||
|
<Flame size={12} className="fill-current" /> {streak} Day Streak Active
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="text-3xl font-black tracking-tight bg-gradient-to-r from-foreground via-foreground to-muted-foreground bg-clip-text text-transparent">
|
||||||
|
{wishTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-md mx-auto pt-2">
|
||||||
|
{wishMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleEnter}
|
||||||
|
className="w-full size-12 rounded-xl text-base font-black bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 shadow-[0_0_20px_-3px_#00F0FF] hover:shadow-[0_0_30px_#00F0FF] transition-all duration-300"
|
||||||
|
>
|
||||||
|
Enter Workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/hooks/useActiveUser.ts
Normal file
118
src/hooks/useActiveUser.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
class: string;
|
||||||
|
target_exam: "JEE_Main" | "JEE_Advanced" | "NEET";
|
||||||
|
medium: "English" | "Hindi" | "Bilingual";
|
||||||
|
school: string | null;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
tier: "Free" | "Pro" | "Al_Coach"; // Al_Coach in database schema
|
||||||
|
streak_count: number;
|
||||||
|
longest_streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"; // AI Coach
|
||||||
|
|
||||||
|
export const DEMO_USERS = {
|
||||||
|
FREE: { id: "11111111-1111-1111-1111-111111111111", label: "Amit Kumar (Free Tier)" },
|
||||||
|
PRO: { id: "22222222-2222-2222-2222-222222222222", label: "Priya Singh (Pro Tier)" },
|
||||||
|
AI_COACH: { id: DEFAULT_USER_ID, label: "Rahul Sharma (AI Coach)" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useActiveUser() {
|
||||||
|
const [userId, setUserIdState] = useState<string>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return localStorage.getItem("activeUserId") || DEFAULT_USER_ID;
|
||||||
|
}
|
||||||
|
return DEFAULT_USER_ID;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchUserProfile = useCallback(async (id: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch user profile");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error fetching active user profile:", err);
|
||||||
|
setError(err.message || "Failed to load user");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const storedId = localStorage.getItem("activeUserId") || DEFAULT_USER_ID;
|
||||||
|
setUserIdState(storedId);
|
||||||
|
fetchUserProfile(storedId);
|
||||||
|
}
|
||||||
|
}, [fetchUserProfile]);
|
||||||
|
|
||||||
|
const switchUser = useCallback((newId: string) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("activeUserId", newId);
|
||||||
|
setUserIdState(newId);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasAccessTo = useCallback((feature: string): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
const tier = user.tier;
|
||||||
|
|
||||||
|
// Core restrictions mapping based on PRD feature matrix
|
||||||
|
switch (feature) {
|
||||||
|
case "topic-wise-microtests-unlimited":
|
||||||
|
case "weekly-test-series":
|
||||||
|
case "full-length-mock-tests":
|
||||||
|
case "previous-year-papers":
|
||||||
|
case "detailed-performance-analytics":
|
||||||
|
case "chapter-wise-progress-tracking":
|
||||||
|
case "doubt-resolution-community":
|
||||||
|
case "parent-dashboard":
|
||||||
|
case "offline-mode":
|
||||||
|
case "pdf-export":
|
||||||
|
return tier === "Pro" || tier === "Al_Coach";
|
||||||
|
|
||||||
|
case "ai-mistake-tracking":
|
||||||
|
case "spaced-repetition":
|
||||||
|
case "personalized-study-plan":
|
||||||
|
case "ai-tutor-chat":
|
||||||
|
case "exam-readiness-score":
|
||||||
|
case "exam-day-strategy":
|
||||||
|
case "college-predictor":
|
||||||
|
case "study-time-optimization":
|
||||||
|
return tier === "Al_Coach";
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Free tier features: DPS (1/day), basic dashboard, basic streak tracking, leaderboard
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
switchUser,
|
||||||
|
hasAccessTo,
|
||||||
|
refreshUser: () => fetchUserProfile(userId)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -238,9 +238,7 @@ export const useCBTStore = create<CBTState>()(
|
|||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
const res = await fetch(`/api/attempts/submit`, {
|
||||||
const apiUrl = rawUrl.replace(/\/$/, '');
|
|
||||||
const res = await fetch(`${apiUrl}/api/attempts/submit`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
Reference in New Issue
Block a user