UI fixes: Admin UI padding, AI Tutor Chat History, DPP filtering and Premium Gates
This commit is contained in:
@@ -445,6 +445,8 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
|
||||
if exam_pattern:
|
||||
if exam_pattern == 'JEE_NEET':
|
||||
pass
|
||||
elif exam_pattern == 'JEE':
|
||||
query = query.where(models.Test.exam_pattern.in_(['JEE_Main', 'JEE_Advanced']))
|
||||
else:
|
||||
query = query.where(models.Test.exam_pattern == exam_pattern)
|
||||
if type:
|
||||
@@ -1839,3 +1841,4 @@ def verify_payment(payload: VerifyPaymentPayload, session: Session = Depends(get
|
||||
|
||||
return {"success": True, "tier": user.tier.value}
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function AdminTestsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function CBTPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/tests?user_id=${userId}&type=Mock`;
|
||||
if (selectedPattern !== "JEE" && selectedPattern !== "JEE_NEET") {
|
||||
if (selectedPattern) {
|
||||
url += `&exam_pattern=${selectedPattern}`;
|
||||
}
|
||||
const testRes = await fetchWithAuth(url);
|
||||
@@ -363,3 +363,4 @@ export default function CBTPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||
import { PremiumGate } from "@/components/PremiumGate"
|
||||
|
||||
export default function AIDailyPracticePage() {
|
||||
const { user, userId } = useActiveUser()
|
||||
@@ -27,9 +28,22 @@ export default function AIDailyPracticePage() {
|
||||
try {
|
||||
const pattern = user.target_exam || "JEE_Main";
|
||||
// Also fetch from the correct Next.js proxied route instead of localhost:8000
|
||||
const res = await fetchWithAuth(`/api/tests?type=DPS&exam_pattern=${pattern}`);
|
||||
const res = await fetchWithAuth(`/api/tests?type=DPS`);
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
let data = await res.json()
|
||||
|
||||
// Filter by subject based on target exam
|
||||
const target = user.target_exam || "JEE_Main";
|
||||
data = data.filter((dpp: any) => {
|
||||
const title = dpp.title.toLowerCase();
|
||||
const isMath = title.includes("math");
|
||||
const isBio = title.includes("bio") || title.includes("botany") || title.includes("zoology");
|
||||
|
||||
if (target === "NEET" && isMath) return false;
|
||||
if (target.startsWith("JEE") && target !== "JEE_NEET" && isBio) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
setDpps(data)
|
||||
}
|
||||
|
||||
@@ -155,7 +169,7 @@ export default function AIDailyPracticePage() {
|
||||
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
||||
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground group-hover:border-[var(--cyan-ai)] transition-colors flex items-center justify-center"></div>}
|
||||
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground flex items-center justify-center opacity-50"></div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -200,28 +214,34 @@ export default function AIDailyPracticePage() {
|
||||
) : isExpired && !isAttempted && !dpp.pdf_url ? (
|
||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
||||
) : dpp.pdf_url ? (
|
||||
<Button onClick={() => setSelectedPdfDpp(dpp)} variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
||||
<FileText size={14} className="text-black" /> View PDF
|
||||
</Button>
|
||||
<PremiumGate tier="PRO" fallback={<Button variant="outline" size="sm" className="opacity-50"><Lock size={14} className="mr-2"/> View PDF</Button>}>
|
||||
<Button onClick={() => setSelectedPdfDpp(dpp)} variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
||||
<FileText size={14} className="text-black" /> View PDF
|
||||
</Button>
|
||||
</PremiumGate>
|
||||
) : isAttempted ? (
|
||||
<>
|
||||
<Link href={`/cbt/${dpp.id}/result`}>
|
||||
<Button variant="outline" size="sm" className="font-bold border-border">Result</Button>
|
||||
</Link>
|
||||
{!isExpired && (
|
||||
<Link href={`/cbt/${dpp.id}`}>
|
||||
<Button variant="secondary" size="sm" className="font-bold bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] hover:bg-[var(--cyan-ai-20)] gap-2">
|
||||
<Play size={14} className="fill-current" /> Retake Test
|
||||
</Button>
|
||||
</Link>
|
||||
<PremiumGate tier="AI_COACH" fallback={<Button variant="outline" size="sm" className="opacity-50"><Lock size={14} className="mr-2"/> Retake</Button>}>
|
||||
<Link href={`/cbt/${dpp.id}`}>
|
||||
<Button variant="secondary" size="sm" className="font-bold bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] hover:bg-[var(--cyan-ai-20)] gap-2">
|
||||
<Play size={14} className="fill-current" /> Retake Test
|
||||
</Button>
|
||||
</Link>
|
||||
</PremiumGate>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Link href={`/cbt/${dpp.id}`}>
|
||||
<Button variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
||||
<Play size={14} className="fill-black" /> Launch Practice Test
|
||||
</Button>
|
||||
</Link>
|
||||
<PremiumGate tier="AI_COACH" fallback={<Button variant="outline" size="sm" className="opacity-50"><Lock size={14} className="mr-2"/> Launch Practice Test</Button>}>
|
||||
<Link href={`/cbt/${dpp.id}`}>
|
||||
<Button variant="secondary" size="sm" className="rounded-xl bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2 text-xs shadow-[0_0_12px_rgba(12,96,96,0.2)]">
|
||||
<Play size={14} className="fill-black" /> Launch Practice Test
|
||||
</Button>
|
||||
</Link>
|
||||
</PremiumGate>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -335,3 +355,4 @@ export default function AIDailyPracticePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fetchWithAuth } from "@/lib/apiClient";
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Send, Loader2, BrainCircuit, Sparkles } from "lucide-react"
|
||||
import { Send, Loader2, BrainCircuit, Sparkles, PlusCircle, MessageSquare } from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkMath from "remark-math"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
@@ -16,6 +16,13 @@ interface Message {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
"Explain Newton's laws with an example",
|
||||
"What is the difference between speed and velocity?",
|
||||
@@ -23,10 +30,11 @@ const QUICK_PROMPTS = [
|
||||
"Solve: Integration of x² from 0 to 3",
|
||||
]
|
||||
|
||||
const INITIAL_MSG: Message = { role: "assistant", content: "Hello! I'm your PrepPilot AI. Ask me anything — from concept explanations to step-by-step problem solving for JEE & NEET topics." }
|
||||
|
||||
export function TutorChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: "assistant", content: "Hello! I'm your PrepPilot AI. Ask me anything — from concept explanations to step-by-step problem solving for JEE & NEET topics." }
|
||||
])
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [activeSessionId, setActiveSessionId] = useState<string>("")
|
||||
const [input, setInput] = useState("")
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -36,21 +44,50 @@ export function TutorChat() {
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
const savedV2 = localStorage.getItem("ai_tutor_chats_v2_" + userId)
|
||||
if (savedV2) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedV2)
|
||||
if (parsed && parsed.length > 0) {
|
||||
setSessions(parsed)
|
||||
setActiveSessionId(parsed[0].id)
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Fallback to legacy
|
||||
const saved = localStorage.getItem("ai_tutor_chat_" + userId)
|
||||
if (saved) {
|
||||
try {
|
||||
setMessages(JSON.parse(saved))
|
||||
const parsedMsgs = JSON.parse(saved)
|
||||
if (parsedMsgs && parsedMsgs.length > 1) {
|
||||
const legacySession = {
|
||||
id: Date.now().toString(),
|
||||
title: "Previous Chat",
|
||||
timestamp: Date.now(),
|
||||
messages: parsedMsgs
|
||||
}
|
||||
setSessions([legacySession])
|
||||
setActiveSessionId(legacySession.id)
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Default empty session
|
||||
handleNewChat()
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
// Save to localStorage whenever messages change
|
||||
// Save to localStorage whenever sessions change
|
||||
useEffect(() => {
|
||||
if (userId && messages.length > 1) { // Only save if there's actual history beyond initial message
|
||||
localStorage.setItem("ai_tutor_chat_" + userId, JSON.stringify(messages))
|
||||
if (userId && sessions.length > 0) {
|
||||
localStorage.setItem("ai_tutor_chats_v2_" + userId, JSON.stringify(sessions))
|
||||
}
|
||||
}, [messages, userId])
|
||||
}, [sessions, userId])
|
||||
|
||||
const activeMessages = sessions.find(s => s.id === activeSessionId)?.messages || [INITIAL_MSG]
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
@@ -58,16 +95,33 @@ export function TutorChat() {
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
}, [activeMessages])
|
||||
|
||||
const handleNewChat = () => {
|
||||
const newId = Date.now().toString()
|
||||
setSessions(prev => [{
|
||||
id: newId,
|
||||
title: "New Chat",
|
||||
timestamp: Date.now(),
|
||||
messages: [INITIAL_MSG]
|
||||
}, ...prev])
|
||||
setActiveSessionId(newId)
|
||||
}
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || streaming || userLoading || !userId) return
|
||||
|
||||
setInput("")
|
||||
setMessages(prev => [...prev, { role: "user", content: text.trim() }])
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "" }])
|
||||
setStreaming(true)
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id === activeSessionId) {
|
||||
const title = s.messages.length === 1 ? text.trim().substring(0, 30) + "..." : s.title;
|
||||
return { ...s, title, messages: [...s.messages, { role: "user", content: text.trim() }, { role: "assistant", content: "" }] }
|
||||
}
|
||||
return s
|
||||
}))
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth("/api/ai-coach/chat/stream", {
|
||||
method: "POST",
|
||||
@@ -84,23 +138,29 @@ export function TutorChat() {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
setMessages(prev => {
|
||||
const msgs = [...prev]
|
||||
msgs[msgs.length - 1].content += chunk
|
||||
return msgs
|
||||
})
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id === activeSessionId) {
|
||||
const msgs = [...s.messages]
|
||||
msgs[msgs.length - 1].content += chunk
|
||||
return { ...s, messages: msgs }
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// Graceful fallback — don't expose internal errors
|
||||
const fallback = "I'm having trouble connecting right now. Please try again in a moment. In the meantime, you can browse your **Study Plan** or review your **Mistake Queue** for offline practice."
|
||||
const words = fallback.split(" ")
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
await new Promise(r => setTimeout(r, 40))
|
||||
setMessages(prev => {
|
||||
const msgs = [...prev]
|
||||
msgs[msgs.length - 1].content += (i === 0 ? "" : " ") + words[i]
|
||||
return msgs
|
||||
})
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id === activeSessionId) {
|
||||
const msgs = [...s.messages]
|
||||
msgs[msgs.length - 1].content += (i === 0 ? "" : " ") + words[i]
|
||||
return { ...s, messages: msgs }
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
@@ -113,8 +173,32 @@ export function TutorChat() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[500px]">
|
||||
<Card className="border-border bg-card shadow-[0_4px_24px_rgba(0,0,0,0.3)] flex-1 overflow-hidden flex flex-col group hover:border-[var(--cyan-ai-30)] transition-colors">
|
||||
<div className="flex h-full min-h-[500px] gap-4 relative">
|
||||
{/* Sidebar for chat history */}
|
||||
<div className="w-64 shrink-0 flex flex-col gap-2 overflow-y-auto hidden md:flex border-r border-border/50 pr-4 py-2">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 bg-[var(--cyan-ai)] text-black font-extrabold hover:bg-[var(--cyan-ai-80)] transition-colors py-2.5 rounded-xl shadow-[0_0_12px_rgba(12,96,96,0.2)]"
|
||||
>
|
||||
<PlusCircle size={18} /> New Chat
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 mt-4">
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2 px-2">Past Chats</span>
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveSessionId(s.id)}
|
||||
className={`text-left px-3 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2 truncate transition-colors ${activeSessionId === s.id ? 'bg-[var(--cyan-ai-20)] text-[var(--cyan-ai)] border border-[var(--cyan-ai-30)]' : 'hover:bg-secondary text-muted-foreground hover:text-primary'}`}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">{s.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<Card className="border-border bg-card shadow-[0_4px_24px_rgba(0,0,0,0.3)] flex-1 overflow-hidden flex flex-col group hover:border-[var(--cyan-ai-30)] transition-colors relative">
|
||||
{/* Chat Header */}
|
||||
<div className="px-4 py-3 bg-[var(--cyan-ai-10)] border-b border-border flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[var(--cyan-ai)] to-blue-500 flex items-center justify-center shrink-0">
|
||||
@@ -126,11 +210,18 @@ export function TutorChat() {
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" /> Online · JEE & NEET specialist
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="md:hidden w-8 h-8 rounded-full bg-[var(--cyan-ai-20)] text-[var(--cyan-ai)] flex items-center justify-center shrink-0 hover:bg-[var(--cyan-ai-30)] transition-transform hover:scale-105"
|
||||
title="New Chat"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((msg, idx) => (
|
||||
{activeMessages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className="max-w-[85%] flex flex-col gap-1">
|
||||
<div className={`p-3.5 text-sm leading-relaxed ${
|
||||
@@ -155,7 +246,7 @@ export function TutorChat() {
|
||||
</CardContent>
|
||||
|
||||
{/* Quick Prompts — shown only when only 1 message (initial) */}
|
||||
{messages.length === 1 && (
|
||||
{activeMessages.length === 1 && (
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-2">
|
||||
{QUICK_PROMPTS.map(p => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user