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>
)
}