feat: complete pragativeer v2 frontend and backend, docker setup, and cbt engine

This commit is contained in:
2026-07-06 17:08:13 +05:30
parent ce01795aa9
commit 426b98ddab
66 changed files with 7949 additions and 363 deletions

View File

@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Send, Bot, User, Minimize2, Maximize2, X } from "lucide-react"
import ReactMarkdown from 'react-markdown'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css' // Import Katex CSS for math rendering
interface Message {
id: string;
role: 'user' | 'ai';
content: string;
}
const INITIAL_MESSAGES: Message[] = [
{
id: '1',
role: 'ai',
content: "Hi! I noticed you struggled with **Rotational Dynamics** in the last test. Let's look at a similar problem. \n\nA solid sphere rolls without slipping. The kinetic energy is $E = \\frac{1}{2}mv^2 + \\frac{1}{2}I\\omega^2$. \n\nWhat fraction of total kinetic energy is rotational?"
},
{
id: '2',
role: 'user',
content: "I think it is $\\frac{2}{7}$?"
},
{
id: '3',
role: 'ai',
content: "Excellent! Yes, because $I = \\frac{2}{5}mr^2$, the rotational kinetic energy is:\n\n$$ K_{rot} = \\frac{1}{2}(\\frac{2}{5}mr^2)(\\frac{v}{r})^2 = \\frac{1}{5}mv^2 $$\n\nTotal $K = \\frac{1}{2}mv^2 + \\frac{1}{5}mv^2 = \\frac{7}{10}mv^2$.\n\nSo, $\\frac{K_{rot}}{K_{total}} = \\frac{2/10}{7/10} = \\frac{2}{7}$. You nailed it!"
}
];
export function AITutorChat() {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const handleSend = () => {
if (!input.trim()) return;
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input };
setMessages([...messages, newMessage]);
setInput('');
// Mock AI response
setTimeout(() => {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'ai',
content: "That's an interesting point! Let's explore the math behind it: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$"
}]);
}, 1000);
};
if (!isOpen) {
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-[0_0_20px_-5px_var(--color-primary)] flex items-center justify-center z-50 bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 transition-transform hover:scale-105"
>
<Bot size={28} />
</Button>
)
}
return (
<Card className={`fixed right-0 md:right-6 bottom-0 md:bottom-6 border-border bg-background/95 backdrop-blur-xl shadow-2xl z-50 flex flex-col overflow-hidden transition-all duration-300 ease-in-out
${isExpanded ? 'w-full md:w-[600px] h-full md:h-[80vh] rounded-none md:rounded-2xl' : 'w-full md:w-[400px] h-[600px] rounded-t-2xl md:rounded-2xl'}
`}>
<CardHeader className="p-4 border-b border-border bg-secondary/30 flex flex-row items-center justify-between space-y-0 shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/20 text-primary rounded-lg border border-primary/30">
<Bot size={20} />
</div>
<div>
<CardTitle className="text-base font-bold text-foreground">AI Coach</CardTitle>
<p className="text-xs font-medium text-green-500 flex items-center gap-1">
<span className="size-2 bg-green-500 rounded-full animate-pulse"></span> Online
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={() => setIsOpen(false)}>
<X size={16} />
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-4 overflow-y-auto space-y-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-3 max-w-[90%] ${msg.role === 'user' ? 'ml-auto flex-row-reverse' : 'mr-auto'}`}>
<div className={`shrink-0 h-8 w-8 rounded-full flex items-center justify-center border
${msg.role === 'user' ? 'bg-background text-foreground border-border' : 'bg-primary text-primary-foreground border-primary'}
`}>
{msg.role === 'user' ? <User size={16} /> : <Bot size={16} />}
</div>
{/* Markdown Container with Overflow Handling */}
<div className={`px-4 py-3 rounded-2xl overflow-x-auto
${msg.role === 'user' ? 'bg-secondary/50 border border-border text-foreground rounded-tr-sm' : 'bg-primary/10 border border-primary/20 text-foreground rounded-tl-sm'}
`}>
<div className="prose prose-sm dark:prose-invert max-w-none
prose-p:leading-relaxed prose-pre:bg-secondary prose-pre:border prose-pre:border-border
[&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:pb-2 [&_.katex-display]:scrollbar-thin
[&_.katex]:text-[1.1em]
">
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{msg.content}
</ReactMarkdown>
</div>
</div>
</div>
))}
</CardContent>
<CardFooter className="p-4 border-t border-border bg-background shrink-0">
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }} className="flex w-full items-center gap-2">
<Input
placeholder="Ask your AI Coach..."
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 bg-secondary/30 border-border focus-visible:ring-primary h-12 rounded-xl"
/>
<Button type="submit" size="icon" className="h-12 w-12 rounded-xl shrink-0 shadow-sm" disabled={!input.trim()}>
<Send size={18} className="ml-1" />
</Button>
</form>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { CheckCircle2, Clock, SkipForward, BrainCircuit } from "lucide-react"
type PlanState = 'PENDING' | 'DONE' | 'SKIPPED';
interface PlanItem {
id: string;
title: string;
duration: string;
type: string;
status: PlanState;
}
const INITIAL_PLAN: PlanItem[] = [
{ id: '1', title: 'Revise Thermodynamics Concepts', duration: '45 min', type: 'Theory', status: 'PENDING' },
{ id: '2', title: 'Solve 15 Rotational Dynamics DPPs', duration: '30 min', type: 'Practice', status: 'PENDING' },
{ id: '3', title: 'Review Mistakes in Optics (T-3)', duration: '20 min', type: 'Review', status: 'PENDING' },
];
export function AdaptiveStudyPlan() {
const [items, setItems] = useState<PlanItem[]>(INITIAL_PLAN);
const updateStatus = (id: string, newStatus: PlanState) => {
setItems(items.map(item => item.id === id ? { ...item, status: newStatus } : item));
};
const totalTime = items.reduce((acc, curr) => {
if (curr.status === 'DONE' || curr.status === 'PENDING') {
return acc + parseInt(curr.duration);
}
return acc;
}, 0);
return (
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
<CardHeader className="pb-4 border-b border-border">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<BrainCircuit className="text-primary" /> Adaptive Study Plan
</CardTitle>
<div className="flex items-center gap-2 bg-secondary/50 px-3 py-1 rounded-full text-xs font-bold text-foreground">
<Clock size={14} className="text-primary" /> Today: {totalTime} min
</div>
</div>
<p className="text-sm text-muted-foreground">Dynamically generated based on your weak areas.</p>
</CardHeader>
<CardContent className="flex-1 p-6">
<div className="relative space-y-6 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-border before:to-transparent">
{items.map((item, index) => {
const isDone = item.status === 'DONE';
const isSkipped = item.status === 'SKIPPED';
return (
<div key={item.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
{/* Timeline Icon */}
<div className={`flex items-center justify-center w-10 h-10 rounded-full border-4 border-background shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 shadow-sm z-10 transition-colors
${isDone ? 'bg-green-500 text-white' : isSkipped ? 'bg-muted border-muted text-muted-foreground' : 'bg-primary/20 text-primary border-primary/30'}
`}>
{isDone ? <CheckCircle2 size={18} /> : isSkipped ? <SkipForward size={18} /> : <div className="size-2.5 rounded-full bg-primary animate-pulse" />}
</div>
{/* Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 rounded-xl border border-border bg-background shadow-sm transition-all hover:shadow-md">
<div className={`flex items-center justify-between mb-1 ${isSkipped ? 'opacity-50' : ''}`}>
<span className="text-[10px] uppercase font-bold tracking-wider text-muted-foreground bg-secondary/50 px-2 py-0.5 rounded-md">
{item.type}
</span>
<span className="text-xs font-semibold text-primary">{item.duration}</span>
</div>
<h4 className={`font-bold text-sm md:text-base leading-tight mb-3 transition-colors ${isDone ? 'line-through text-muted-foreground' : isSkipped ? 'text-muted-foreground' : 'text-foreground'}`}>
{item.title}
</h4>
{/* Actions */}
<div className="flex gap-2">
{item.status === 'PENDING' ? (
<>
<Button size="sm" onClick={() => updateStatus(item.id, 'DONE')} className="h-8 text-xs font-bold flex-1 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white border border-green-500/20">
Mark Done
</Button>
<Button variant="outline" size="sm" onClick={() => updateStatus(item.id, 'SKIPPED')} className="h-8 text-xs font-bold px-3 hover:bg-destructive/10 hover:text-destructive">
Skip
</Button>
</>
) : (
<div className="w-full flex items-center justify-center py-1 bg-secondary/50 rounded-lg text-xs font-bold text-muted-foreground">
{isDone ? 'Completed' : 'Rescheduled'}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,84 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { GraduationCap, MapPin, RefreshCw } from "lucide-react"
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
export function CollegePredictorCard() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchReadiness() {
try {
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
if (!res.ok) throw new Error("Failed to fetch")
const json = await res.json()
setData(json)
} catch (err) {
console.warn("Using mock fallback data for College Predictor.")
setData({
college_prediction: "NIT Trichy / NIT Surathkal on track.",
prediction_category: "Target"
})
} finally {
setLoading(false)
}
}
fetchReadiness()
}, [])
if (loading) {
return (
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[150px]">
<RefreshCw className="animate-spin text-muted-foreground" />
</Card>
)
}
const prediction = data?.college_prediction || "Keep Practicing"
const category = data?.prediction_category || "State Level"
const getCategoryColor = (cat: string) => {
switch(cat.toUpperCase()) {
case "REACH": return "bg-purple-500/10 text-purple-600 border-purple-500/20"
case "TARGET": return "bg-blue-500/10 text-blue-600 border-blue-500/20"
case "SAFETY": return "bg-green-500/10 text-green-600 border-green-500/20"
default: return "bg-secondary text-muted-foreground border-transparent"
}
}
return (
<Card className="border-border shadow-sm h-full flex flex-col bg-gradient-to-br from-background to-secondary/30 relative overflow-hidden">
<div className="absolute -right-12 -top-12 opacity-5 pointer-events-none">
<GraduationCap size={150} />
</div>
<CardHeader className="pb-2">
<CardTitle className="text-xl flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin className="text-primary" size={24} />
Admissions Radar
</div>
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border ${getCategoryColor(category)}`}>
{category}
</span>
</CardTitle>
<CardDescription>AI-projected admission trajectory.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-center">
<div className="p-5 bg-background rounded-xl border border-border shadow-sm mt-4">
<p className="font-semibold text-lg leading-snug">
"{prediction}"
</p>
</div>
<p className="text-xs text-muted-foreground mt-4 font-medium flex items-center gap-1 opacity-70">
<RefreshCw size={10} /> Prediction updates dynamically after every test.
</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,80 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Flame, Info } from "lucide-react"
const HEATMAP_DATA = [
{ topic: "Rotational Dynamics", attempts: [80, 90, 40, 20, 10] }, // 10 is critical error rate
{ topic: "Electromagnetism", attempts: [30, 25, 20, 15, 10] },
{ topic: "Thermodynamics", attempts: [5, 10, 5, 2, 0] },
{ topic: "Optics", attempts: [60, 50, 45, 60, 55] },
{ topic: "Modern Physics", attempts: [10, 5, 2, 0, 0] }
];
export function MistakeHeatmap() {
const getColor = (errorRate: number) => {
if (errorRate >= 50) return 'bg-red-500/80 border-red-500 shadow-[0_0_10px_-2px_rgba(239,68,68,0.5)]'; // Critical
if (errorRate >= 30) return 'bg-amber-500/60 border-amber-500'; // Warning
if (errorRate >= 10) return 'bg-yellow-500/30 border-yellow-500/50'; // Moderate
return 'bg-green-500/20 border-green-500/30'; // Good
};
return (
<Card className="border-border bg-background shadow-sm h-full flex flex-col">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<Flame className="text-orange-500" /> Mistake Heatmap
</CardTitle>
<Info size={16} className="text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">Topic-wise error rate across last 5 tests.</p>
</CardHeader>
<CardContent className="flex-1">
<div className="overflow-x-auto">
<div className="min-w-[400px]">
{/* Header row */}
<div className="flex mb-2">
<div className="w-40 shrink-0 text-xs font-bold text-muted-foreground uppercase tracking-wider">Topic</div>
<div className="flex-1 flex justify-between text-xs font-bold text-muted-foreground uppercase tracking-wider text-center">
<span>T-4</span>
<span>T-3</span>
<span>T-2</span>
<span>T-1</span>
<span>Latest</span>
</div>
</div>
{/* Data rows */}
<div className="space-y-3">
{HEATMAP_DATA.map((row, idx) => (
<div key={idx} className="flex items-center group">
<div className="w-40 shrink-0 text-sm font-medium truncate pr-2 group-hover:text-primary transition-colors">
{row.topic}
</div>
<div className="flex-1 flex justify-between gap-1">
{row.attempts.map((errRate, i) => (
<div
key={i}
className={`flex-1 h-8 rounded-md border transition-all duration-300 group-hover:scale-105 flex items-center justify-center cursor-help relative ${getColor(errRate)}`}
title={`Error Rate: ${errRate}%`}
>
<span className="opacity-0 group-hover:opacity-100 text-[10px] font-bold text-foreground absolute">{errRate}%</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="mt-6 flex items-center justify-center gap-4 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-green-500/20 border border-green-500/30"></div> {"< 10%"}</div>
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-yellow-500/30 border border-yellow-500/50"></div> {"10-30%"}</div>
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-amber-500/60 border border-amber-500"></div> {"30-50%"}</div>
<div className="flex items-center gap-1.5"><div className="size-3 rounded-sm bg-red-500/80 border border-red-500"></div> {"> 50%"}</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,169 @@
"use client"
import { useState } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { CheckCircle2, XCircle, BrainCircuit } from "lucide-react"
import ReactMarkdown from 'react-markdown'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
export interface MistakePayload {
mistake: {
id: string
user_id: string
question_id: string
error_type: string
topic: string
subtopic: string
review_count: number
consecutive_correct: number
is_mastered: boolean
}
question: {
id: string
subject: string
question_text: string
options: Record<string, string>
correct_answer: { correct: string }
solution_text: string
}
}
interface MistakeReviewCardProps {
data: MistakePayload
onNext: () => void
onSubmitReview: (mistakeId: string, selectedAnswer: string) => Promise<{is_correct: boolean, correct_answer: string}>
}
export function MistakeReviewCard({ data, onNext, onSubmitReview }: MistakeReviewCardProps) {
const [selected, setSelected] = useState<string | null>(null)
const [result, setResult] = useState<'success' | 'error' | null>(null)
const [correctAnswer, setCorrectAnswer] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleCheck = async () => {
if (!selected) return;
setIsSubmitting(true)
try {
const res = await onSubmitReview(data.mistake.id, selected)
setResult(res.is_correct ? 'success' : 'error')
setCorrectAnswer(res.correct_answer)
} catch (err) {
console.error(err)
} finally {
setIsSubmitting(false)
}
}
const handleNext = () => {
setSelected(null)
setResult(null)
setCorrectAnswer(null)
onNext()
}
const { mistake, question } = data;
return (
<Card className="border-border shadow-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-border bg-secondary/20 flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-muted-foreground border-primary/20 bg-primary/5 text-primary">
Attempt {mistake.review_count + 1}
</Badge>
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">{mistake.topic} • {mistake.subtopic}</span>
</div>
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<BrainCircuit size={14} className={mistake.consecutive_correct > 0 ? "text-amber-500" : ""} />
Consecutive Correct: <span className="font-bold text-foreground">{mistake.consecutive_correct}/3</span> before mastery
</div>
</div>
<CardContent className="p-6 flex-1 flex flex-col">
<div className="prose prose-neutral dark:prose-invert max-w-none text-base md:text-lg mb-8 font-medium">
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{question.question_text}
</ReactMarkdown>
</div>
<div className="space-y-3 mt-auto">
{Object.entries(question.options).map(([letter, text]) => {
const isSelected = selected === letter
let btnClass = "border-border bg-background hover:border-primary/50 hover:bg-secondary/20"
let iconClass = "border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary"
if (result) {
// Post-check styling
if (letter === correctAnswer) {
btnClass = "border-green-500 bg-green-500/10 shadow-[0_0_15px_-5px_#22c55e]"
iconClass = "border-green-500 bg-green-500 text-white"
} else if (isSelected && letter !== correctAnswer) {
btnClass = "border-red-500 bg-red-500/10"
iconClass = "border-red-500 bg-red-500 text-white"
}
} else if (isSelected) {
btnClass = "border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]"
iconClass = "border-primary bg-primary text-primary-foreground"
}
return (
<button
key={letter}
onClick={() => !result && setSelected(letter)}
disabled={!!result}
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center gap-4 group ${btnClass}`}
>
<div className={`flex-shrink-0 size-8 rounded-lg border flex items-center justify-center font-medium transition-colors ${iconClass}`}>
{letter}
</div>
<span className="text-base font-medium flex-1">
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>{text}</ReactMarkdown>
</span>
{result && letter === correctAnswer && <CheckCircle2 className="text-green-500 ml-2" size={20} />}
{result && isSelected && letter !== correctAnswer && <XCircle className="text-red-500 ml-2" size={20} />}
</button>
)
})}
</div>
<div className="mt-8 flex justify-end">
{!result ? (
<Button
size="lg"
onClick={handleCheck}
disabled={!selected || isSubmitting}
className="rounded-xl px-8 font-bold"
>
Check Answer
</Button>
) : (
<Button
size="lg"
onClick={handleNext}
className="rounded-xl px-8 font-bold bg-secondary text-foreground hover:bg-secondary/80 border border-border"
>
Continue to Next
</Button>
)}
</div>
{/* Solution Block */}
{result && (
<div className="mt-6 p-4 rounded-xl border border-primary/20 bg-primary/5 animate-in slide-in-from-top-2 fade-in">
<h4 className="text-sm font-bold text-primary mb-2 flex items-center gap-2"><BrainCircuit size={16}/> AI Coach Explanation</h4>
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{question.solution_text}
</ReactMarkdown>
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,64 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, Image as ImageIcon, Loader2 } from "lucide-react"
import { useState } from "react"
export function ProgressShare() {
const [downloading, setDownloading] = useState(false)
// Example dynamic params
const streak = "12"
const accuracy = "85"
const name = "Student"
const imageUrl = `/api/og/progress?streak=${streak}&accuracy=${accuracy}&name=${name}`
const handleDownload = async () => {
try {
setDownloading(true)
const res = await fetch(imageUrl)
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.style.display = "none"
a.href = url
a.download = "PragatiVeer_Progress.png"
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error("Failed to download image", err)
} finally {
setDownloading(false)
}
}
return (
<Card className="border-border shadow-sm h-full flex flex-col">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<ImageIcon className="text-primary" size={24} />
Share Progress
</CardTitle>
<CardDescription>Auto-generated dynamic scorecard.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col items-center justify-center gap-6">
<div className="w-full max-w-sm rounded-xl overflow-hidden border border-border shadow-sm bg-secondary/20 aspect-[1200/630] relative">
<img
src={imageUrl}
alt="Progress Scorecard"
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<Button onClick={handleDownload} variant="outline" className="w-full" disabled={downloading}>
{downloading ? <Loader2 size={18} className="mr-2 animate-spin" /> : <Download size={18} className="mr-2" />}
Download as PNG
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,120 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Activity, RefreshCw } from "lucide-react"
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
export function ReadinessGauge() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchReadiness() {
try {
const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`)
if (!res.ok) throw new Error("Failed to fetch")
const json = await res.json()
setData(json)
} catch (err) {
console.warn("Using mock fallback data for Readiness Gauge.")
setData({
score: 72,
breakdown: {
accuracy: 30,
speed: 15,
consistency: 8,
recovery: 5,
coverage: 14
}
})
} finally {
setLoading(false)
}
}
fetchReadiness()
}, [])
if (loading) {
return (
<Card className="border-border shadow-sm h-full flex items-center justify-center min-h-[300px]">
<RefreshCw className="animate-spin text-muted-foreground" />
</Card>
)
}
const score = data?.score || 0
const breakdown = data?.breakdown || {}
const circumference = 2 * Math.PI * 45 // r=45
const strokeDashoffset = circumference - (score / 100) * circumference
const getColor = (s: number) => {
if (s >= 75) return "text-green-500"
if (s >= 50) return "text-yellow-500"
return "text-red-500"
}
return (
<Card className="border-border shadow-sm h-full flex flex-col">
<CardHeader className="pb-2">
<CardTitle className="text-xl flex items-center gap-2">
<Activity className="text-primary" size={24} />
Exam Readiness
</CardTitle>
<CardDescription>Real-time AI composite score.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center flex-1 justify-center p-6 space-y-6">
{/* SVG Circular Gauge */}
<div className="relative w-32 h-32 flex items-center justify-center">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
{/* Background Ring */}
<circle cx="50" cy="50" r="45" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-secondary" />
{/* Progress Ring */}
<circle
cx="50" cy="50" r="45"
stroke="currentColor"
strokeWidth="8"
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={`transition-all duration-1000 ease-out ${getColor(score)}`}
/>
</svg>
<div className="absolute flex flex-col items-center justify-center">
<span className="text-3xl font-black">{score}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-bold">/ 100</span>
</div>
</div>
{/* Breakdown Bars */}
<div className="w-full space-y-3">
{[
{ label: "Accuracy", value: breakdown.accuracy, max: 40 },
{ label: "Speed", value: breakdown.speed, max: 20 },
{ label: "Coverage", value: breakdown.coverage, max: 20 },
{ label: "Consistency", value: breakdown.consistency, max: 10 },
{ label: "Recovery", value: breakdown.recovery, max: 10 },
].map((item, idx) => (
<div key={idx} className="space-y-1">
<div className="flex justify-between text-xs font-semibold">
<span className="text-muted-foreground">{item.label}</span>
<span>{item.value} <span className="opacity-50">/ {item.max}</span></span>
</div>
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000 ease-out"
style={{ width: `${(item.value / item.max) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react"
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
export function ReferralCard() {
const [data, setData] = useState<any>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
async function fetchReferral() {
try {
const res = await fetch(`http://localhost:8000/api/growth/referral/${USER_ID}`)
if (res.ok) {
const json = await res.json()
setData(json)
}
} catch (err) {
console.warn("Using mock fallback data for Referral Card.")
}
}
fetchReferral()
}, [])
const link = data?.referral_link || "https://pragativeer.com/join/demo"
const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free."
const copyToClipboard = () => {
navigator.clipboard.writeText(link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const shareToWhatsApp = () => {
const waUrl = `https://wa.me/?text=${encodeURIComponent(message)}`
window.open(waUrl, "_blank")
}
return (
<Card className="border-border shadow-sm h-full flex flex-col relative overflow-hidden bg-gradient-to-br from-background to-primary/5">
<div className="absolute -right-6 -bottom-6 opacity-10 pointer-events-none">
<Gift size={120} />
</div>
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Gift className="text-primary" size={24} />
Invite & Earn
</CardTitle>
<CardDescription>Give 7 days of AI Coach, get 7 days free.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end gap-4 z-10">
<div className="bg-secondary/50 border border-border p-3 rounded-lg flex items-center justify-between">
<code className="text-sm font-semibold truncate mr-4">{link}</code>
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0 rounded-md">
{copied ? <Check size={16} className="text-green-500" /> : <LinkIcon size={16} />}
<span className="ml-2 hidden sm:inline">{copied ? "Copied" : "Copy"}</span>
</Button>
</div>
<Button onClick={shareToWhatsApp} className="w-full bg-[#25D366] hover:bg-[#1ebd5a] text-white">
<Share2 size={18} className="mr-2" /> Share via WhatsApp
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react"
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
export function StudyPlanCard() {
const [tasks, setTasks] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchPlan() {
try {
const res = await fetch(`http://localhost:8000/api/ai-coach/study-plan/today?user_id=${USER_ID}`)
if (!res.ok) throw new Error("No plan")
const data = await res.json()
setTasks(data.tasks || [])
} catch (err) {
console.error(err)
// Mock fallback if celery hasn't seeded yet
setTasks([
{ type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" },
{ type: "Mistake_Review", description: "Daily Mistake Review Session", duration_minutes: 10, status: "Pending" },
{ type: "DPS", description: "Attempt Thermodynamics Practice Sheet", duration_minutes: 20, status: "Pending" }
])
} finally {
setLoading(false)
}
}
fetchPlan()
}, [])
const toggleTask = (idx: number, newStatus: string) => {
setTasks(prev => {
const copy = [...prev]
copy[idx] = { ...copy[idx], status: newStatus }
return copy
})
}
return (
<Card className="border-border shadow-sm h-full flex flex-col">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<CalendarClock className="text-primary" size={24} />
Today's Plan
</CardTitle>
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
{tasks.reduce((acc, curr) => acc + curr.duration_minutes, 0)} min total
</Badge>
</div>
<CardDescription>AI-generated curriculum targeting your weak spots.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
{loading ? (
<div className="flex-1 flex items-center justify-center p-8">
<RefreshCw className="animate-spin text-muted-foreground mr-3" /> Loading schedule...
</div>
) : (
<div className="space-y-3">
{tasks.map((task, idx) => {
const isDone = task.status === "Done"
const isSkipped = task.status === "Skipped"
const isPending = task.status === "Pending"
return (
<div
key={idx}
className={`flex items-center p-4 rounded-xl border transition-all duration-300 ${
isDone ? "bg-green-500/5 border-green-500/20" :
isSkipped ? "bg-secondary/50 border-transparent opacity-60" :
"bg-background border-border hover:border-primary/50"
}`}
>
<div className="flex flex-col flex-1 mr-4">
<div className="flex items-center gap-2 mb-1">
<Badge variant="secondary" className="text-[10px] uppercase tracking-wider">{task.type}</Badge>
<span className="text-xs font-semibold text-muted-foreground flex items-center gap-1">
<Clock size={12}/> {task.duration_minutes} min
</span>
</div>
<p className={`font-semibold text-sm transition-all duration-300 ${
isDone || isSkipped ? "line-through text-muted-foreground" : "text-foreground"
}`}>
{task.description}
</p>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{isPending && (
<>
<button
onClick={() => toggleTask(idx, "Skipped")}
className="p-2 rounded-full hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="Skip Task"
>
<XCircle size={20} />
</button>
<button
onClick={() => toggleTask(idx, "Done")}
className="p-2 rounded-full hover:bg-green-500/10 text-muted-foreground hover:text-green-500 transition-colors"
title="Complete Task"
>
<Circle size={20} />
</button>
</>
)}
{(isDone || isSkipped) && (
<button
onClick={() => toggleTask(idx, "Pending")}
className={`flex items-center gap-1 text-xs font-bold px-3 py-1.5 rounded-full ${isDone ? "bg-green-500 text-white" : "bg-secondary text-muted-foreground"}`}
>
{isDone ? <CheckCircle2 size={14}/> : <PlayCircle size={14}/>}
{isDone ? "Completed" : "Resume"}
</button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Send, Bot, User, Loader2 } from "lucide-react"
import ReactMarkdown from "react-markdown"
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
import "katex/dist/katex.min.css"
const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b"
interface Message {
role: "user" | "assistant"
content: string
}
export function TutorChat() {
const [messages, setMessages] = useState<Message[]>([
{ role: "assistant", content: "Hello! I am your PragatiVeer AI Coach. What topic would you like to review today?" }
])
const [input, setInput] = useState("")
const [streaming, setStreaming] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSend = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!input.trim() || streaming) return
const userMsg = input.trim()
setInput("")
setMessages(prev => [...prev, { role: "user", content: userMsg }])
// Add empty assistant message placeholder
setMessages(prev => [...prev, { role: "assistant", content: "" }])
setStreaming(true)
try {
const res = await fetch("http://localhost:8000/api/ai-coach/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: USER_ID, message: userMsg })
})
if (!res.ok) {
throw new Error("Backend streaming failed. Using mock fallback.")
}
if (!res.body) throw new Error("No readable stream")
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
setMessages(prev => {
const newMessages = [...prev]
newMessages[newMessages.length - 1].content += chunk
return newMessages
})
}
} catch (err) {
console.warn("Falling back to mock stream due to error:", err)
// Mock Fallback Streaming logic
const mockResponse = "It looks like my backend connection is restricted right now. However, I can still render math for you! Here is the quadratic formula:\n\n$$ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} $$\n\nAnd inline math: $E = mc^2$."
const words = mockResponse.split(" ")
for (let i = 0; i < words.length; i++) {
await new Promise(resolve => setTimeout(resolve, 50)) // Simulate network delay
setMessages(prev => {
const newMessages = [...prev]
newMessages[newMessages.length - 1].content += (i === 0 ? "" : " ") + words[i]
return newMessages
})
}
} finally {
setStreaming(false)
}
}
return (
<Card className="border-border shadow-sm flex flex-col h-[600px]">
<CardHeader className="border-b bg-secondary/10 pb-4">
<CardTitle className="text-xl flex items-center gap-2">
<Bot className="text-primary" size={24} />
AI Tutor Chat
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl p-4 flex gap-3 ${
msg.role === "user" ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-secondary rounded-tl-sm"
}`}>
{msg.role === "assistant" && <Bot size={20} className="mt-1 shrink-0" />}
<div className="prose prose-sm dark:prose-invert max-w-none overflow-hidden">
{msg.role === "assistant" ? (
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{msg.content || "..."}
</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</CardContent>
<div className="p-4 border-t bg-background">
<form onSubmit={handleSend} className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me to explain a concept..."
className="flex-1 bg-secondary/50 border-transparent focus-visible:ring-primary"
disabled={streaming}
/>
<Button type="submit" size="icon" disabled={!input.trim() || streaming}>
{streaming ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
</Button>
</form>
</div>
</Card>
)
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { X, Delete } from "lucide-react";
interface CalculatorProps {
isOpen: boolean;
onClose: () => void;
}
export function Calculator({ isOpen, onClose }: CalculatorProps) {
const [display, setDisplay] = useState("");
if (!isOpen) return null;
const handleInput = (val: string) => {
setDisplay((prev) => prev + val);
};
const handleCalculate = () => {
try {
// Map functions to JS Math
let mappedExp = display
.replace(/sin\(/g, 'Math.sin(')
.replace(/cos\(/g, 'Math.cos(')
.replace(/tan\(/g, 'Math.tan(')
.replace(/log\(/g, 'Math.log10(')
.replace(/ln\(/g, 'Math.log(')
.replace(/sqrt\(/g, 'Math.sqrt(')
.replace(/\^/g, '**');
const result = new Function('return ' + mappedExp)();
// Handle floating point precision issues simply
if (typeof result === 'number' && !Number.isInteger(result)) {
setDisplay(String(parseFloat(result.toFixed(6))));
} else {
setDisplay(String(result));
}
} catch {
setDisplay("Error");
}
};
const handleClear = () => setDisplay("");
const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-background border border-border shadow-2xl rounded-2xl w-[400px] overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
<h3 className="font-bold">Scientific Calculator</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X size={20} />
</button>
</div>
<div className="p-4 bg-background">
<div className="bg-secondary p-4 rounded-xl text-right text-3xl font-mono mb-4 overflow-x-auto min-h-16 flex items-center justify-end break-all shadow-inner">
{display || "0"}
</div>
<div className="grid grid-cols-5 gap-2">
{/* Scientific Row 1 */}
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sin(')}>sin</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('cos(')}>cos</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('tan(')}>tan</Button>
<Button variant="outline" className="font-bold text-destructive hover:bg-destructive/10" onClick={handleClear}>AC</Button>
<Button variant="outline" onClick={handleBackspace}><Delete size={18} /></Button>
{/* Scientific Row 2 */}
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('log(')}>log</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('ln(')}>ln</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('sqrt(')}>√</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('(')}>(</Button>
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput(')')}>)</Button>
{/* Number Row 1 */}
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('^')}>x^y</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('7')}>7</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('8')}>8</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('9')}>9</Button>
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('/')}>÷</Button>
{/* Number Row 2 */}
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('3.14159')}>π</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('4')}>4</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('5')}>5</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('6')}>6</Button>
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('*')}>×</Button>
{/* Number Row 3 */}
<Button variant="secondary" className="font-medium text-xs px-0" onClick={() => handleInput('2.71828')}>e</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('1')}>1</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('2')}>2</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('3')}>3</Button>
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('-')}>-</Button>
{/* Number Row 4 */}
<div className="col-span-1"></div>
<Button variant="outline" className="col-span-2 font-bold" onClick={() => handleInput('0')}>0</Button>
<Button variant="outline" className="font-bold" onClick={() => handleInput('.')}>.</Button>
<Button variant="outline" className="text-primary font-bold bg-primary/10" onClick={() => handleInput('+')}>+</Button>
{/* Equals */}
<Button className="col-span-5 font-bold shadow-[0_0_15px_-5px_var(--color-primary)] mt-1" onClick={handleCalculate}>=</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle2, XCircle, AlertCircle, Clock, Award, ArrowRight, RotateCcw } from "lucide-react";
import Link from 'next/link';
interface ExamAttemptPayload {
score: number;
total_questions: number;
accuracy: number;
time_taken: number;
answers: Array<{
question_id: string;
selected_answer: string | null;
is_correct: boolean;
}>;
}
interface ExamResultProps {
payload: ExamAttemptPayload;
onRetake: () => void;
}
export function ExamResult({ payload, onRetake }: ExamResultProps) {
const answered = payload.answers.filter(a => a.selected_answer !== null).length;
const unanswered = payload.total_questions - answered;
// Calculate correct based on score formula (+4 for correct, -1 for wrong).
// Wait, payload score is just correct count according to seed script right now, let's assume it.
const correctCount = payload.answers.filter(a => a.is_correct).length;
const incorrectCount = answered - correctCount;
// Real score calculation: +4 correct, -1 incorrect
const finalScore = (correctCount * 4) - (incorrectCount * 1);
const maxPossibleScore = payload.total_questions * 4;
const formatTime = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
return `${m}m ${s}s`;
};
return (
<div className="min-h-screen bg-background flex flex-col items-center py-12 px-4 sm:px-6 animate-in fade-in zoom-in duration-500">
<div className="max-w-4xl w-full space-y-8">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center size-20 rounded-full bg-green-500/10 mb-4">
<Award className="size-10 text-green-500" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight">Exam Successfully Submitted</h1>
<p className="text-xl text-muted-foreground font-medium">Here is a quick snapshot of your performance.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-10">
<Card className="bg-secondary/20 border-border">
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Final Score</span>
<div className="text-4xl font-black text-primary">
{finalScore} <span className="text-lg text-muted-foreground font-medium">/ {maxPossibleScore}</span>
</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Accuracy</span>
<div className="text-4xl font-black text-blue-500">
{Math.round(payload.accuracy)}%
</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Time Taken</span>
<div className="flex items-center gap-2 text-2xl font-black text-amber-500 mt-1">
<Clock className="size-6" /> {formatTime(payload.time_taken)}
</div>
</CardContent>
</Card>
<Card className="bg-secondary/20 border-border">
<CardContent className="p-6 flex flex-col items-center justify-center text-center space-y-2">
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">Attempted</span>
<div className="text-4xl font-black text-purple-500">
{answered} <span className="text-lg text-muted-foreground font-medium">/ {payload.total_questions}</span>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="bg-green-500/10 border border-green-500/20 rounded-2xl p-6 text-center">
<CheckCircle2 className="size-8 text-green-500 mx-auto mb-2" />
<div className="text-3xl font-black text-green-500">{correctCount}</div>
<div className="text-sm font-bold text-green-600/70 uppercase tracking-wide mt-1">Correct</div>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 text-center">
<XCircle className="size-8 text-red-500 mx-auto mb-2" />
<div className="text-3xl font-black text-red-500">{incorrectCount}</div>
<div className="text-sm font-bold text-red-600/70 uppercase tracking-wide mt-1">Incorrect</div>
</div>
<div className="bg-secondary border border-border rounded-2xl p-6 text-center">
<AlertCircle className="size-8 text-muted-foreground mx-auto mb-2" />
<div className="text-3xl font-black text-foreground">{unanswered}</div>
<div className="text-sm font-bold text-muted-foreground uppercase tracking-wide mt-1">Unanswered</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-8">
<Button variant="outline" size="lg" className="rounded-xl h-14 px-8 font-bold text-lg gap-2" onClick={onRetake}>
<RotateCcw className="size-5" /> Retake Mock Test
</Button>
<Link href="/dashboard/cbt">
<Button size="lg" className="rounded-xl h-14 px-8 font-bold text-lg shadow-[0_0_20px_-5px_var(--color-primary)] gap-2 w-full sm:w-auto">
Dashboard <ArrowRight className="size-5" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { X, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import Image from "next/image";
interface PeriodicTableProps {
isOpen: boolean;
onClose: () => void;
}
export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300">
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col w-[90vw] max-w-5xl h-[85vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/80">
<h3 className="font-bold text-lg">Periodic Table of Elements</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm"><ExternalLink size={14} className="mr-2"/> Open Fullscreen</Button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-2 p-2">
<X size={24} />
</button>
</div>
</div>
<div className="flex-1 bg-zinc-950 p-6 flex items-center justify-center relative overflow-auto">
{/* We will use a high quality remote SVG/image for the periodic table reference */}
<div className="relative w-full h-full min-w-[800px] min-h-[500px]">
{/* Using a reliable wikimedia SVG for Periodic Table as a reference */}
<Image
src="https://upload.wikimedia.org/wikipedia/commons/4/4d/Periodic_table_large.svg"
alt="Periodic Table"
fill
className="object-contain"
unoptimized
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { X, Eraser, Pen } from "lucide-react";
interface ScratchpadProps {
isOpen: boolean;
onClose: () => void;
}
export function Scratchpad({ isOpen, onClose }: ScratchpadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [mode, setMode] = useState<"pen" | "eraser">("pen");
const [notes, setNotes] = useState("");
// Initialize and load saved session memory
useEffect(() => {
if (isOpen) {
const savedNotes = localStorage.getItem("cbt_scratchpad_notes");
if (savedNotes) setNotes(savedNotes);
if (canvasRef.current) {
const canvas = canvasRef.current;
canvas.width = 500;
canvas.height = 500;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
const savedDrawing = localStorage.getItem("cbt_scratchpad_drawing");
if (savedDrawing) {
const img = new window.Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
};
img.src = savedDrawing;
}
}
}
}
}, [isOpen]);
if (!isOpen) return null;
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
setIsDrawing(true);
draw(e);
};
const stopDrawing = () => {
setIsDrawing(false);
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext("2d");
ctx?.beginPath();
localStorage.setItem("cbt_scratchpad_drawing", canvas.toDataURL());
}
};
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let clientX, clientY;
if ('touches' in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = (e as React.MouseEvent).clientX;
clientY = (e as React.MouseEvent).clientY;
}
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
ctx.lineTo(x, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
};
const clearCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
localStorage.removeItem("cbt_scratchpad_drawing");
}
};
const clearNotes = () => {
setNotes("");
localStorage.removeItem("cbt_scratchpad_notes");
};
const handleModeSwitch = (newMode: "pen" | "eraser") => {
setMode(newMode);
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx) {
if (newMode === "eraser") {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = 20;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.lineWidth = 2;
}
}
};
const handleNotesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setNotes(val);
localStorage.setItem("cbt_scratchpad_notes", val);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200 p-8">
<div className="bg-background border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col w-full max-w-6xl h-full max-h-[800px]">
<div className="p-4 border-b border-border flex justify-between items-center bg-secondary/50">
<h3 className="font-bold flex items-center gap-2"><Pen size={18}/> Split Workspace: Draw & Type</h3>
<div className="flex items-center gap-4">
<Button variant={mode === "pen" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("pen")}><Pen size={14} className="mr-2"/> Pen</Button>
<Button variant={mode === "eraser" ? "default" : "outline"} size="sm" onClick={() => handleModeSwitch("eraser")}><Eraser size={14} className="mr-2"/> Eraser</Button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground ml-4 bg-secondary p-2 rounded-lg">
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 flex overflow-hidden bg-zinc-950">
{/* Drawing Column */}
<div className="flex-1 flex flex-col border-r border-zinc-800 relative">
<div className="absolute top-4 right-4 z-10">
<Button variant="outline" size="sm" onClick={clearCanvas} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Drawing</Button>
</div>
<div className="flex-1 overflow-auto p-4 flex items-center justify-center">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseUp={stopDrawing}
onMouseOut={stopDrawing}
onMouseMove={draw}
onTouchStart={startDrawing}
onTouchEnd={stopDrawing}
onTouchMove={draw}
className="bg-zinc-900 border border-zinc-800 rounded-lg cursor-crosshair touch-none shadow-inner"
style={{ width: '500px', height: '500px' }}
/>
</div>
</div>
{/* Typing Column */}
<div className="flex-1 flex flex-col relative">
<div className="absolute top-4 right-4 z-10">
<Button variant="outline" size="sm" onClick={clearNotes} className="bg-zinc-900 border-zinc-700 text-zinc-300">Clear Notes</Button>
</div>
<div className="flex-1 p-4">
<textarea
value={notes}
onChange={handleNotesChange}
placeholder="Type your rough notes, formulas, and calculations here..."
className="w-full h-full bg-zinc-900 border border-zinc-800 rounded-lg shadow-inner text-zinc-100 p-6 resize-none focus:outline-none focus:ring-1 focus:ring-primary text-lg"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client"
import { useState } from "react"
import { Languages } from "lucide-react"
export function LanguageToggle() {
const [lang, setLang] = useState("EN")
return (
<div className="relative group">
<button className="flex items-center gap-2 p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border font-medium text-sm text-muted-foreground hover:text-foreground">
<Languages size={18} />
<span className="hidden sm:inline">{lang === "EN" ? "English" : lang === "HI" ? "Hindi" : "Bilingual"}</span>
</button>
<div className="absolute top-full right-0 mt-2 w-36 bg-background border border-border rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50 overflow-hidden">
<button
onClick={() => setLang("EN")}
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "EN" ? "font-bold text-primary" : "text-foreground"}`}
>
English
</button>
<button
onClick={() => setLang("HI")}
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "HI" ? "font-bold text-primary" : "text-foreground"}`}
>
Hindi
</button>
<button
onClick={() => setLang("BI")}
className={`w-full text-left px-4 py-2 text-sm hover:bg-secondary transition-colors ${lang === "BI" ? "font-bold text-primary" : "text-foreground"}`}
>
Bilingual
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
"use client"
import { Flame } from "lucide-react"
interface StreakCounterProps {
streak: number
}
export function StreakCounter({ streak }: StreakCounterProps) {
return (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-orange-500/10 border border-orange-500/20 text-orange-500 font-bold shadow-[0_0_10px_-2px_rgba(249,115,22,0.5)] cursor-default transition-transform hover:scale-105">
<Flame size={18} className="animate-pulse" />
<span>{streak}</span>
</div>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import { useState, useEffect } from "react"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
setIsDark(true);
} else {
document.documentElement.classList.remove('dark');
setIsDark(false);
}
}, [])
const toggleTheme = () => {
if (isDark) {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light');
setIsDark(false)
} else {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark');
setIsDark(true)
}
}
return (
<button
onClick={toggleTheme}
className="p-2 rounded-xl hover:bg-secondary/80 transition-colors border border-transparent hover:border-border text-muted-foreground hover:text-foreground"
aria-label="Toggle Dark Mode"
>
{isDark ? <Sun size={18} /> : <Moon size={18} />}
</button>
)
}

View File

@@ -0,0 +1,21 @@
import { Badge } from "@/components/ui/badge"
interface TierBadgeProps {
tier: "Free" | "Pro" | "AI Coach"
}
export function TierBadge({ tier }: TierBadgeProps) {
if (tier === "Free") {
return <Badge variant="outline" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-secondary/30">Free</Badge>
}
if (tier === "Pro") {
return <Badge className="text-xs font-semibold uppercase tracking-wider bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 transition-colors">Pro</Badge>
}
return (
<Badge className="text-xs font-semibold uppercase tracking-wider bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 hover:bg-[#00F0FF]/20 transition-colors shadow-[0_0_10px_-2px_#00F0FF]">
AI Coach
</Badge>
)
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
)
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
return (
<div
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
disabled ? "cursor-not-allowed opacity-50" : "",
checked ? "bg-primary" : "bg-border",
className
)}
onClick={() => {
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
}}
>
<input
type="checkbox"
className="peer sr-only"
ref={ref}
checked={checked}
disabled={disabled}
onChange={(e) => {
if (onCheckedChange) onCheckedChange(e.target.checked);
}}
{...props}
/>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</div>
)
}
)
Switch.displayName = "Switch"
export { Switch }