feat: admin date-time calendar controller, dpp ui cleanup, 4-subject jee+neet tracker, calendar filter, and clean new account state
This commit is contained in:
@@ -899,8 +899,8 @@ DEMO_DEFINITIONS = {
|
|||||||
"state": "Bihar",
|
"state": "Bihar",
|
||||||
"school": "Loyola High School",
|
"school": "Loyola High School",
|
||||||
"tier": models.TierEnum.FREE,
|
"tier": models.TierEnum.FREE,
|
||||||
"streak_count": 1,
|
"streak_count": 0,
|
||||||
"longest_streak": 1,
|
"longest_streak": 0,
|
||||||
# trial_end is intentionally omitted here — it is set dynamically at creation time
|
# trial_end is intentionally omitted here — it is set dynamically at creation time
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1110,6 @@ DEMO_USER_UUIDS = {
|
|||||||
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
"11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE)
|
||||||
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET)
|
"22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET)
|
||||||
"33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET)
|
"33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET)
|
||||||
"44444444-4444-4444-4444-444444444444", # Amit (Free - JEE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/users/{user_id}/stats", tags=["Users"])
|
@app.get("/api/users/{user_id}/stats", tags=["Users"])
|
||||||
@@ -1343,6 +1342,7 @@ async def upload_admin_test(
|
|||||||
exam_pattern: str = Form(...),
|
exam_pattern: str = Form(...),
|
||||||
time_limit: int = Form(...),
|
time_limit: int = Form(...),
|
||||||
scheduled_date: Optional[datetime] = Form(None),
|
scheduled_date: Optional[datetime] = Form(None),
|
||||||
|
end_date: Optional[datetime] = Form(None),
|
||||||
admin_id: Optional[UUID] = Form(None),
|
admin_id: Optional[UUID] = Form(None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
@@ -1388,6 +1388,7 @@ async def upload_admin_test(
|
|||||||
total_time=time_limit,
|
total_time=time_limit,
|
||||||
difficulty="Medium",
|
difficulty="Medium",
|
||||||
scheduled_date=scheduled_date,
|
scheduled_date=scheduled_date,
|
||||||
|
end_date=end_date,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
created_by=effective_admin_id,
|
created_by=effective_admin_id,
|
||||||
pdf_url=url
|
pdf_url=url
|
||||||
@@ -1534,6 +1535,42 @@ def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), c
|
|||||||
|
|
||||||
return {"status": "success", "is_active": test_obj.is_active}
|
return {"status": "success", "is_active": test_obj.is_active}
|
||||||
|
|
||||||
|
class UpdateTestScheduleInput(BaseModel):
|
||||||
|
scheduled_date: Optional[datetime] = None
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
@app.put("/api/admin/tests/{test_id}/schedule", tags=["Admin"])
|
||||||
|
def update_test_schedule(
|
||||||
|
test_id: UUID,
|
||||||
|
data: UpdateTestScheduleInput,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: models.User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
test_obj = session.get(models.Test, test_id)
|
||||||
|
if not test_obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Test not found")
|
||||||
|
|
||||||
|
test_obj.scheduled_date = data.scheduled_date
|
||||||
|
test_obj.end_date = data.end_date
|
||||||
|
if data.title is not None:
|
||||||
|
test_obj.title = data.title
|
||||||
|
if data.is_active is not None:
|
||||||
|
test_obj.is_active = data.is_active
|
||||||
|
session.add(test_obj)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(test_obj)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"test_id": test_obj.id,
|
||||||
|
"title": test_obj.title,
|
||||||
|
"scheduled_date": test_obj.scheduled_date,
|
||||||
|
"end_date": test_obj.end_date,
|
||||||
|
"is_active": test_obj.is_active
|
||||||
|
}
|
||||||
|
|
||||||
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"])
|
@app.delete("/api/admin/tests/{test_id}", tags=["Admin"])
|
||||||
def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef, useMemo } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react"
|
import {
|
||||||
|
Loader2, Upload, FileText, Clock, Calendar as CalendarIcon,
|
||||||
|
CheckCircle2, AlertCircle, X, Trash2, CalendarRange,
|
||||||
|
SlidersHorizontal, Check, RefreshCw, Zap, ShieldAlert
|
||||||
|
} from "lucide-react"
|
||||||
import DatePicker from "react-datepicker"
|
import DatePicker from "react-datepicker"
|
||||||
import "react-datepicker/dist/react-datepicker.css"
|
import "react-datepicker/dist/react-datepicker.css"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
|
||||||
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
||||||
const QuestionSchema = z.preprocess((val: any) => {
|
const QuestionSchema = z.preprocess((val: any) => {
|
||||||
@@ -119,6 +124,18 @@ export default function AdminTestsPage() {
|
|||||||
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([])
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Calendar-based controller state
|
||||||
|
const [adminDateFilter, setAdminDateFilter] = useState("")
|
||||||
|
const [adminTypeFilter, setAdminTypeFilter] = useState("All")
|
||||||
|
|
||||||
|
// Schedule Editing Modal State
|
||||||
|
const [editingTest, setEditingTest] = useState<any | null>(null)
|
||||||
|
const [editTitle, setEditTitle] = useState("")
|
||||||
|
const [editScheduledStart, setEditScheduledStart] = useState<Date | null>(null)
|
||||||
|
const [editScheduledEnd, setEditScheduledEnd] = useState<Date | null>(null)
|
||||||
|
const [editIsActive, setEditIsActive] = useState(true)
|
||||||
|
const [savingSchedule, setSavingSchedule] = useState(false)
|
||||||
|
|
||||||
const fetchTests = async () => {
|
const fetchTests = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth("/api/admin/tests")
|
const res = await fetchWithAuth("/api/admin/tests")
|
||||||
@@ -134,7 +151,6 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
fetchTests()
|
fetchTests()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -169,6 +185,78 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openScheduleModal = (test: any) => {
|
||||||
|
setEditingTest(test)
|
||||||
|
setEditTitle(test.title || "")
|
||||||
|
setEditScheduledStart(test.scheduled_date ? new Date(test.scheduled_date) : null)
|
||||||
|
setEditScheduledEnd(test.end_date ? new Date(test.end_date) : null)
|
||||||
|
setEditIsActive(test.is_active ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSchedule = async () => {
|
||||||
|
if (!editingTest) return
|
||||||
|
setSavingSchedule(true)
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth(`/api/admin/tests/${editingTest.id}/schedule`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: editTitle,
|
||||||
|
scheduled_date: editScheduledStart ? editScheduledStart.toISOString() : null,
|
||||||
|
end_date: editScheduledEnd ? editScheduledEnd.toISOString() : null,
|
||||||
|
is_active: editIsActive
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingTest(null)
|
||||||
|
fetchTests()
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
alert(`Failed to update schedule: ${JSON.stringify(err)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert("Error saving schedule changes.")
|
||||||
|
} finally {
|
||||||
|
setSavingSchedule(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick preset actions for calendar schedule controller
|
||||||
|
const setLiveNowPreset = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const end = new Date(Date.now() + 24 * 3600 * 1000)
|
||||||
|
setEditScheduledStart(now)
|
||||||
|
setEditScheduledEnd(end)
|
||||||
|
setEditIsActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTodayPreset = () => {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(9, 0, 0, 0)
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
setEditScheduledStart(start)
|
||||||
|
setEditScheduledEnd(end)
|
||||||
|
setEditIsActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTomorrowPreset = () => {
|
||||||
|
const start = new Date(Date.now() + 86400000)
|
||||||
|
start.setHours(9, 0, 0, 0)
|
||||||
|
const end = new Date(Date.now() + 86400000)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
setEditScheduledStart(start)
|
||||||
|
setEditScheduledEnd(end)
|
||||||
|
setEditIsActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearScheduleBounds = () => {
|
||||||
|
setEditScheduledStart(null)
|
||||||
|
setEditScheduledEnd(null)
|
||||||
|
setEditIsActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
@@ -255,6 +343,7 @@ export default function AdminTestsPage() {
|
|||||||
formData.append("time_limit", timeLimit)
|
formData.append("time_limit", timeLimit)
|
||||||
formData.append("admin_id", effectiveAdminId)
|
formData.append("admin_id", effectiveAdminId)
|
||||||
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
||||||
|
if (endDate) formData.append("end_date", endDate.toISOString())
|
||||||
|
|
||||||
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -263,6 +352,8 @@ export default function AdminTestsPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("PDF uploaded successfully to Minio and connected!")
|
alert("PDF uploaded successfully to Minio and connected!")
|
||||||
setTitle("")
|
setTitle("")
|
||||||
|
setScheduledDate(null)
|
||||||
|
setEndDate(null)
|
||||||
removeFile()
|
removeFile()
|
||||||
fetchTests()
|
fetchTests()
|
||||||
} else {
|
} else {
|
||||||
@@ -305,6 +396,8 @@ export default function AdminTestsPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Test created successfully!")
|
alert("Test created successfully!")
|
||||||
setTitle("")
|
setTitle("")
|
||||||
|
setScheduledDate(null)
|
||||||
|
setEndDate(null)
|
||||||
removeFile()
|
removeFile()
|
||||||
fetchTests()
|
fetchTests()
|
||||||
} else {
|
} else {
|
||||||
@@ -319,50 +412,84 @@ export default function AdminTestsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All scheduled dates for CalendarFilter dots
|
||||||
|
const allScheduledDates = useMemo(() => {
|
||||||
|
return tests
|
||||||
|
.map(t => t.scheduled_date ? getLocalDateString(t.scheduled_date) : "")
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [tests])
|
||||||
|
|
||||||
|
// Filter tests by Type and Schedule Date
|
||||||
|
const filteredTests = useMemo(() => {
|
||||||
|
return tests.filter(test => {
|
||||||
|
if (adminTypeFilter !== "All") {
|
||||||
|
if (adminTypeFilter === "DPP_PDF" && !test.pdf_url) return false
|
||||||
|
if (adminTypeFilter === "DPS" && (test.type !== "DPS" || test.pdf_url)) return false
|
||||||
|
if (adminTypeFilter === "Mock" && test.type !== "Mock") return false
|
||||||
|
}
|
||||||
|
if (adminDateFilter) {
|
||||||
|
if (!test.scheduled_date) return false
|
||||||
|
if (getLocalDateString(test.scheduled_date) !== adminDateFilter) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [tests, adminTypeFilter, adminDateFilter])
|
||||||
|
|
||||||
|
const formatScheduleDateTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs</h1>
|
<div>
|
||||||
<p className="text-muted-foreground mt-2">Manage CBT examinations and daily practice sheets</p>
|
<h1 className="text-3xl font-bold tracking-tight">Tests & DPPs Manager</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Date-wise calendar scheduling, live time-bound controllers, and examination repositories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
{/* Upload Form */}
|
{/* Upload & Create Form */}
|
||||||
<Card className="lg:col-span-1 border-muted bg-card shadow-sm h-fit">
|
<Card className="lg:col-span-1 border-muted bg-card shadow-sm h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5 text-primary" />
|
<Upload className="w-5 h-5 text-primary" />
|
||||||
Create Manual Test
|
Create / Schedule Test
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Upload a structured JSON array of questions in LaTeX format to create a deterministic test.</CardDescription>
|
<CardDescription>Upload structured question JSON or attach a class DPP PDF with exact calendar timings.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleUpload} className="space-y-4">
|
<form onSubmit={handleUpload} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Test Title</label>
|
<label className="text-sm font-medium">Test / DPP Title</label>
|
||||||
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" />
|
<Input required value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Physics Ch-4 DPP (Electrostatics)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TEST TYPE — KEY DIFFERENTIATOR */}
|
{/* TEST TYPE */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium flex items-center gap-2">
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
Test Type
|
Test Type
|
||||||
<span className="text-[10px] text-muted-foreground font-normal">(determines which section it appears in)</span>
|
<span className="text-[10px] text-muted-foreground font-normal">(Placement Target)</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
value={testType}
|
value={testType}
|
||||||
onChange={(e) => setTestType(e.target.value)}
|
onChange={(e) => setTestType(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="Mock">🎯 CBT Test (JSON) — appears in Mock Tests section</option>
|
<option value="Mock">🎯 CBT Test (JSON) — Mock Tests Section</option>
|
||||||
<option value="DPS">🤖 Prep Pilot Test (as CBT JSON) — appears in AI Coach</option>
|
<option value="DPS">🤖 Prep Pilot Test (CBT JSON) — AI Coach</option>
|
||||||
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Class/Plan aligned</option>
|
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Daily Practice Section</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{testType === 'Mock' && '✅ Purely for CBT. Appears under Dashboard → Mock Tests.'}
|
{testType === 'Mock' && '✅ Appears in Student Dashboard → Mock Tests (CBT).'}
|
||||||
{testType === 'DPS' && '✅ Purely for CBT. Appears under AI Coach → PrepPilot AI.'}
|
{testType === 'DPS' && '✅ Appears in Student Dashboard → AI Coach → PrepPilot AI.'}
|
||||||
{testType === 'DPP_PDF' && '✅ Uploads a PDF to Minio storage for Class/Plan aligned DPP.'}
|
{testType === 'DPP_PDF' && '✅ Appears in Student Dashboard → Daily Practice (DPP).'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,6 +503,7 @@ export default function AdminTestsPage() {
|
|||||||
<option value="JEE_Main">JEE Main</option>
|
<option value="JEE_Main">JEE Main</option>
|
||||||
<option value="JEE_Advanced">JEE Advanced</option>
|
<option value="JEE_Advanced">JEE Advanced</option>
|
||||||
<option value="NEET">NEET</option>
|
<option value="NEET">NEET</option>
|
||||||
|
<option value="JEE_NEET">JEE & NEET (Combined)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,43 +512,48 @@ export default function AdminTestsPage() {
|
|||||||
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
<Input required type="number" value={timeLimit} onChange={e => setTimeLimit(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Schedule Boundation Controller */}
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="p-3.5 bg-secondary/20 rounded-xl border border-border/80 space-y-3">
|
||||||
<label className="text-sm font-medium">Scheduled Start</label>
|
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
|
||||||
<DatePicker
|
<CalendarRange size={15} className="text-[var(--cyan-ai)]" /> Schedule Boundation (Start & End)
|
||||||
selected={scheduledDate}
|
|
||||||
onChange={(date: Date | null) => setScheduledDate(date)}
|
|
||||||
showTimeSelect
|
|
||||||
timeFormat="HH:mm"
|
|
||||||
timeIntervals={15}
|
|
||||||
timeCaption="time"
|
|
||||||
dateFormat="MMMM d, yyyy h:mm aa"
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
placeholderText="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label className="text-sm font-medium">Scheduled End</label>
|
<div className="space-y-1.5 flex flex-col">
|
||||||
<DatePicker
|
<label className="text-[11px] font-bold text-muted-foreground">Scheduled Start</label>
|
||||||
selected={endDate}
|
<DatePicker
|
||||||
onChange={(date: Date | null) => setEndDate(date)}
|
selected={scheduledDate}
|
||||||
showTimeSelect
|
onChange={(date: Date | null) => setScheduledDate(date)}
|
||||||
timeFormat="HH:mm"
|
showTimeSelect
|
||||||
timeIntervals={15}
|
timeFormat="HH:mm"
|
||||||
timeCaption="time"
|
timeIntervals={15}
|
||||||
dateFormat="MMMM d, yyyy h:mm aa"
|
timeCaption="Time"
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
dateFormat="MMM d, yyyy h:mm aa"
|
||||||
placeholderText="Optional"
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
placeholderText="Starts Immediately"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 flex flex-col">
|
||||||
|
<label className="text-[11px] font-bold text-muted-foreground">Scheduled End</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={endDate}
|
||||||
|
onChange={(date: Date | null) => setEndDate(date)}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={15}
|
||||||
|
timeCaption="Time"
|
||||||
|
dateFormat="MMM d, yyyy h:mm aa"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholderText="No Expiry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<label className="text-sm font-medium">Questions JSON File</label>
|
<label className="text-sm font-medium">Questions JSON or PDF File</label>
|
||||||
|
|
||||||
{testType === 'DPP_PDF' ? (
|
{testType === 'DPP_PDF' ? (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-1">
|
||||||
<label className="text-sm font-medium">PDF File (Minio Upload)</label>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
@@ -475,9 +608,6 @@ export default function AdminTestsPage() {
|
|||||||
<span>{err.message}</span>
|
<span>{err.message}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{validationErrors.length > 50 && (
|
|
||||||
<li className="text-xs text-muted-foreground italic mt-2">...and {validationErrors.length - 50} more errors</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -485,75 +615,285 @@ export default function AdminTestsPage() {
|
|||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
||||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
||||||
{uploading ? "Creating Test..." : (testType === 'DPP_PDF' ? "Upload PDF to Minio" : "Create Test from JSON")}
|
{uploading ? "Publishing..." : (testType === 'DPP_PDF' ? "Upload & Schedule PDF DPP" : "Create Test from JSON")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Existing Tests List */}
|
{/* Existing Tests List & Calendar Controller */}
|
||||||
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
<Card className="lg:col-span-2 border-muted bg-card shadow-sm h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<FileText className="w-5 h-5 text-primary" />
|
<div>
|
||||||
Test Repository
|
<CardTitle className="flex items-center gap-2">
|
||||||
</CardTitle>
|
<CalendarRange className="w-5 h-5 text-primary" />
|
||||||
<CardDescription>Manage visibility and schedule for parsed tests.</CardDescription>
|
Scheduled Tests & DPPs Controller
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Filter by date, adjust start/end time windows, and manage visibility.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchTests} className="gap-1.5 self-start sm:self-auto text-xs font-bold">
|
||||||
|
<RefreshCw size={14} /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Controllers Toolbar */}
|
||||||
|
<div className="pt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<CalendarFilter
|
||||||
|
selectedDate={adminDateFilter}
|
||||||
|
onSelectDate={setAdminDateFilter}
|
||||||
|
availableDates={allScheduledDates}
|
||||||
|
placeholder="Filter by Schedule Date"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-xl">
|
||||||
|
{["All", "DPP_PDF", "DPS", "Mock"].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdminTypeFilter(t)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
adminTypeFilter === t
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "All" ? "All Types" : t === "DPP_PDF" ? "DPP (PDF)" : t === "DPS" ? "PrepPilot" : "CBT Mocks"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs font-bold text-muted-foreground">
|
||||||
|
Showing {filteredTests.length} of {tests.length} items
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
<div className="flex justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /></div>
|
||||||
) : tests.length === 0 ? (
|
) : filteredTests.length === 0 ? (
|
||||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
|
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-xl">
|
||||||
No tests uploaded yet.
|
No tests or DPPs match the selected filter criteria.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3.5">
|
||||||
{tests.map(test => (
|
{filteredTests.map(test => {
|
||||||
<div key={test.id} className="flex items-center justify-between p-4 border rounded-lg bg-background">
|
const now = new Date()
|
||||||
<div className="space-y-1">
|
const startTime = test.scheduled_date ? new Date(test.scheduled_date) : null
|
||||||
<div className="flex items-center gap-2">
|
const endTime = test.end_date ? new Date(test.end_date) : null
|
||||||
<h3 className="font-semibold">{test.title}</h3>
|
const isUpcoming = startTime ? startTime > now : false
|
||||||
<Badge variant="outline">{test.exam_pattern?.replace("_", " ") || "All"}</Badge>
|
const isExpired = endTime ? now > endTime : false
|
||||||
<Badge variant={test.is_active ? "default" : "secondary"}>
|
const isLive = !isUpcoming && !isExpired
|
||||||
{test.is_active ? "Active" : "Inactive / Processing"}
|
|
||||||
</Badge>
|
return (
|
||||||
</div>
|
<div key={test.id} className="p-4 border rounded-xl bg-background hover:border-primary/40 transition-colors shadow-sm">
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="space-y-1.5 flex-1 min-w-0">
|
||||||
<Clock className="w-3 h-3" />
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{test.total_time} mins
|
<h3 className="font-bold text-base truncate">{test.title}</h3>
|
||||||
</div>
|
<Badge variant="outline" className="text-[10px] font-bold">
|
||||||
{test.scheduled_date && (
|
{test.pdf_url ? "DPP (PDF)" : test.type === "DPS" ? "PrepPilot (DPS)" : "CBT Mock"}
|
||||||
<div className="flex items-center gap-1">
|
</Badge>
|
||||||
<CalendarIcon className="w-3 h-3" />
|
<Badge variant="outline" className="text-[10px] uppercase font-bold border-primary/20 bg-primary/5 text-primary">
|
||||||
{new Date(test.scheduled_date).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
{test.exam_pattern?.replace("_", " ") || "All"}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Live status pill */}
|
||||||
|
{isUpcoming ? (
|
||||||
|
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 border-amber-500/20 text-[10px] font-bold">
|
||||||
|
🔒 Upcoming
|
||||||
|
</Badge>
|
||||||
|
) : isExpired ? (
|
||||||
|
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20 text-[10px] font-bold">
|
||||||
|
⏳ Expired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20 text-[10px] font-bold">
|
||||||
|
🟢 Live Now
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge variant={test.is_active ? "default" : "secondary"} className="text-[10px]">
|
||||||
|
{test.is_active ? "Published" : "Hidden"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 font-medium">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{test.total_time} mins
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{test.scheduled_date ? (
|
||||||
|
<div className="flex items-center gap-1 font-semibold text-foreground">
|
||||||
|
<span className="text-muted-foreground font-normal">Starts:</span> {formatScheduleDateTime(test.scheduled_date)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Always Available</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{test.end_date && (
|
||||||
|
<div className="flex items-center gap-1 font-semibold text-foreground border-l border-border pl-3">
|
||||||
|
<span className="text-muted-foreground font-normal">Ends:</span> {formatScheduleDateTime(test.end_date)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0 self-end sm:self-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openScheduleModal(test)}
|
||||||
|
className="gap-1.5 rounded-lg text-xs font-bold border-[var(--cyan-ai-30)] hover:bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] h-8"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} /> Edit Timing
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1.5 border-l border-border pl-2.5">
|
||||||
|
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} title="Toggle published status" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(test.id)}
|
||||||
|
title="Delete test"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
)
|
||||||
<div className="flex items-center gap-2">
|
})}
|
||||||
<span className="text-sm font-medium">{test.is_active ? "Published" : "Hidden"}</span>
|
|
||||||
<Switch checked={test.is_active} onCheckedChange={() => handleToggle(test.id)} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => handleDelete(test.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SCHEDULE CONTROLLER MODAL */}
|
||||||
|
{editingTest && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background border border-border rounded-2xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-5 border-b border-border bg-secondary/30 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-2 bg-[var(--cyan-ai-10)] rounded-xl border border-[var(--cyan-ai-20)] text-[var(--cyan-ai)]">
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-extrabold text-base">Edit Time Boundation & Schedule</h3>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium truncate max-w-xs">{editingTest.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingTest(null)}
|
||||||
|
className="w-8 h-8 rounded-lg bg-secondary text-muted-foreground hover:text-foreground flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Title</label>
|
||||||
|
<Input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Preset Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Quick Timing Presets</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setLiveNowPreset}
|
||||||
|
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span> Live Now (Next 24h)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setTodayPreset}
|
||||||
|
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[var(--cyan-ai)]"></span> Today (09 AM - 12 AM)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setTomorrowPreset}
|
||||||
|
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-yellow-500"></span> Tomorrow Full Day
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearScheduleBounds}
|
||||||
|
className="p-2 rounded-xl bg-secondary hover:bg-secondary/80 text-xs font-bold text-left transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-muted-foreground"></span> Always Available (24/7)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Precise Date/Time Pickers */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 flex flex-col">
|
||||||
|
<label className="text-xs font-bold text-foreground">Scheduled Start Time</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={editScheduledStart}
|
||||||
|
onChange={(date: Date | null) => setEditScheduledStart(date)}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={15}
|
||||||
|
timeCaption="Time"
|
||||||
|
dateFormat="MMM d, yyyy h:mm aa"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold"
|
||||||
|
placeholderText="Immediate / Open"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">When access opens for students</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 flex flex-col">
|
||||||
|
<label className="text-xs font-bold text-foreground">Scheduled End Time</label>
|
||||||
|
<DatePicker
|
||||||
|
selected={editScheduledEnd}
|
||||||
|
onChange={(date: Date | null) => setEditScheduledEnd(date)}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={15}
|
||||||
|
timeCaption="Time"
|
||||||
|
dateFormat="MMM d, yyyy h:mm aa"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold"
|
||||||
|
placeholderText="No Expiry"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">When test expires / closes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Switch */}
|
||||||
|
<div className="flex items-center justify-between p-3.5 bg-secondary/30 rounded-xl border border-border">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-foreground">Published / Active</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Students can see this test when scheduled</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={editIsActive} onCheckedChange={setEditIsActive} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border bg-secondary/20 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setEditingTest(null)} disabled={savingSchedule}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveSchedule} disabled={savingSchedule} className="bg-[var(--cyan-ai)] text-black hover:bg-[var(--cyan-ai-80)] font-extrabold gap-2">
|
||||||
|
{savingSchedule ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
|
||||||
|
{savingSchedule ? "Saving Changes..." : "Apply & Save Timing"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState, useMemo } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
|
||||||
export default function DPPPage() {
|
export default function DPPPage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -80,20 +81,42 @@ export default function DPPPage() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
|
const scheduledDates = useMemo(() => {
|
||||||
|
return dpps
|
||||||
|
.filter((d) => !d.pdf_url && !!d.scheduled_date)
|
||||||
|
.map((d) => getLocalDateString(d.scheduled_date))
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [dpps])
|
||||||
|
|
||||||
const filteredDpps = dpps.filter(dpp => {
|
const filteredDpps = dpps.filter(dpp => {
|
||||||
// Only show CBT DPPs here (no PDF)
|
// Only show CBT DPPs here (no PDF)
|
||||||
if (dpp.pdf_url) return false;
|
if (dpp.pdf_url) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!dpp.scheduled_date) return false;
|
if (!dpp.scheduled_date) return false;
|
||||||
const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0];
|
const scheduledDate = getLocalDateString(dpp.scheduled_date);
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPdfUrl = (title: string) => {
|
const getPdfUrl = (title: string) => {
|
||||||
@@ -124,23 +147,19 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-border pb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<input
|
<CalendarFilter
|
||||||
type="date"
|
selectedDate={dateFilter}
|
||||||
value={dateFilter}
|
onSelectDate={setDateFilter}
|
||||||
onChange={(e) => setDateFilter(e.target.value)}
|
availableDates={scheduledDates}
|
||||||
className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2"
|
placeholder="Filter by Schedule Date"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-bold text-muted-foreground mr-2"><Filter size={16} className="inline mr-1"/> Difficulty:</span>
|
{dateFilter && (
|
||||||
{['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => (
|
<span className="text-xs font-semibold text-muted-foreground bg-secondary/60 px-2.5 py-1 rounded-lg">
|
||||||
<button key={diff} className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${diff === 'Mixed' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-muted-foreground border-border hover:border-primary/50'}`}>
|
Showing for {dateFilter}
|
||||||
{diff}
|
</span>
|
||||||
</button>
|
)}
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-1 ml-2 text-xs text-muted-foreground bg-secondary/50 px-2 py-1 rounded-md">
|
|
||||||
<Lock size={12}/> Pro
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -171,22 +190,32 @@ export default function DPPPage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Date logic
|
// Date & Time logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : null;
|
||||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||||
const isUpcoming = scheduledTime > now;
|
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
|
const isLive = !isUpcoming && !isExpired;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-70 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-80' : 'hover:border-[var(--cyan-ai-10)] bg-background'}`}>
|
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-85 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-70' : 'hover:border-[var(--cyan-ai-20)] bg-background'}`}>
|
||||||
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<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="flex gap-4 items-start w-full">
|
||||||
<div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
||||||
{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>}
|
<div className="mt-1 flex-shrink-0">
|
||||||
|
{isAttempted ? (
|
||||||
|
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" title="Completed">
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1.5 rounded-lg bg-secondary/60 text-[var(--cyan-ai)] border border-border" title="Practice Test">
|
||||||
|
<Zap size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
||||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||||
</span>
|
</span>
|
||||||
@@ -195,14 +224,30 @@ export default function DPPPage() {
|
|||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
<span className="text-[10px] font-bold uppercase text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||||
|
🔒 Upcoming
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-2 py-0.5 rounded-md">
|
||||||
|
⏳ Expired
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase text-emerald-500 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-md">
|
||||||
|
🟢 Live Now
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAttempted && (
|
||||||
|
<span className="text-[10px] font-bold uppercase text-green-500 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-md">
|
||||||
|
Attempted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||||
{cleanTitle(dpp.title)}
|
{cleanTitle(dpp.title)}
|
||||||
@@ -211,10 +256,25 @@ export default function DPPPage() {
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||||
{count} Questions • {dpp.total_time} mins
|
<span>{count} Questions • {dpp.total_time} mins</span>
|
||||||
{dpp.scheduled_date && (
|
{isUpcoming && scheduledTime && (
|
||||||
<span className="ml-2 border-l border-border pl-2">
|
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
||||||
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
Opens on {formatDateTime(dpp.scheduled_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpired && endTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-destructive font-semibold">
|
||||||
|
Expired on {formatDateTime(dpp.end_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLive && endTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
||||||
|
Closes on {formatDateTime(dpp.end_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLive && !endTime && scheduledTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
||||||
|
Available since {formatDateTime(dpp.scheduled_date)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,9 +282,13 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
||||||
|
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
||||||
|
</Button>
|
||||||
) : isExpired && !isAttempted ? (
|
) : isExpired && !isAttempted ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
||||||
|
Expired
|
||||||
|
</Button>
|
||||||
) : isAttempted ? (
|
) : isAttempted ? (
|
||||||
<>
|
<>
|
||||||
<Link href={`/cbt/${dpp.id}/result`}>
|
<Link href={`/cbt/${dpp.id}/result`}>
|
||||||
@@ -256,7 +320,8 @@ export default function DPPPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)})}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */}
|
{/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BrainCircuit, Sparkles } from "lucide-react"
|
|||||||
|
|
||||||
export default function TutorChatPage() {
|
export default function TutorChatPage() {
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-100px)] w-full flex flex-col pt-4 max-w-7xl mx-auto px-4 gap-4">
|
<div className="h-[calc(100vh-100px)] w-full flex flex-col pt-4 max-w-7xl mx-auto px-4 gap-4 animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
<div className="border-b border-border/50 pb-4">
|
<div className="border-b border-border/50 pb-4">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-r
|
|||||||
import { useActiveUser } from "@/hooks/useActiveUser";
|
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||||
import { PremiumGate } from "@/components/PremiumGate";
|
import { PremiumGate } from "@/components/PremiumGate";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter";
|
||||||
|
|
||||||
interface TestData {
|
interface TestData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -91,21 +94,51 @@ export default function CBTPage() {
|
|||||||
loadTests();
|
loadTests();
|
||||||
}, [userId, selectedPattern]);
|
}, [userId, selectedPattern]);
|
||||||
|
|
||||||
|
const scheduledDates = useMemo(() => {
|
||||||
|
return exams
|
||||||
|
.filter((e) => !!e.scheduled_date)
|
||||||
|
.map((e) => getLocalDateString(e.scheduled_date))
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [exams]);
|
||||||
|
|
||||||
const filteredExams = exams.filter(exam => {
|
const filteredExams = exams.filter(exam => {
|
||||||
const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase());
|
||||||
if (!matchesSearch) return false;
|
if (!matchesSearch) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!exam.scheduled_date) return false;
|
if (!exam.scheduled_date) return false;
|
||||||
const scheduledDate = new Date(exam.scheduled_date).toISOString().split('T')[0];
|
const scheduledDate = getLocalDateString(exam.scheduled_date);
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the highest score for each test if attempted multiple times
|
// Get the highest score for each test if attempted multiple times
|
||||||
@@ -143,15 +176,13 @@ export default function CBTPage() {
|
|||||||
Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.
|
Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||||
<div className="relative w-full sm:w-48">
|
<CalendarFilter
|
||||||
<input
|
selectedDate={dateFilter}
|
||||||
type="date"
|
onSelectDate={setDateFilter}
|
||||||
value={dateFilter}
|
availableDates={scheduledDates}
|
||||||
onChange={(e) => setDateFilter(e.target.value)}
|
placeholder="Filter by Exam Date"
|
||||||
className="w-full px-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-foreground dark:[color-scheme:dark] [color-scheme:light]"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||||
<input
|
<input
|
||||||
@@ -241,14 +272,32 @@ export default function CBTPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
<div className="text-sm text-muted-foreground space-y-1 mb-6">
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'}
|
{isUpcoming && exam.scheduled_date ? (
|
||||||
|
<span className="text-amber-500 font-semibold">
|
||||||
|
Opens: {formatDateTime(exam.scheduled_date)}
|
||||||
|
</span>
|
||||||
|
) : isExpired && exam.end_date ? (
|
||||||
|
<span className="text-destructive font-semibold">
|
||||||
|
Expired on {formatDateTime(exam.end_date)}
|
||||||
|
</span>
|
||||||
|
) : exam.end_date ? (
|
||||||
|
<span className="text-emerald-500 font-semibold">
|
||||||
|
Live Now • Closes {formatDateTime(exam.end_date)}
|
||||||
|
</span>
|
||||||
|
) : exam.scheduled_date ? (
|
||||||
|
<span className="text-emerald-500 font-semibold">
|
||||||
|
Available since {formatDateTime(exam.scheduled_date)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-500 font-semibold">Available Now</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
<div>{exam.total_time} Min • {exam.difficulty} Difficulty</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled className="w-full gap-2 rounded-xl bg-secondary text-muted-foreground font-bold">
|
<Button disabled className="w-full gap-2 rounded-xl bg-amber-500/10 text-amber-500 border border-amber-500/30 font-bold">
|
||||||
<Clock size={16} /> Starts Soon
|
<Clock size={16} /> Opens at {exam.scheduled_date ? formatTime(exam.scheduled_date) : "Scheduled Time"}
|
||||||
</Button>
|
</Button>
|
||||||
) : isLocked ? (
|
) : isLocked ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState, useMemo } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
|
||||||
export default function AIDailyPracticePage() {
|
export default function AIDailyPracticePage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -81,21 +82,43 @@ export default function AIDailyPracticePage() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
|
const scheduledDates = useMemo(() => {
|
||||||
|
return dpps
|
||||||
|
.filter((d) => !!d.pdf_url && !!d.scheduled_date)
|
||||||
|
.map((d) => getLocalDateString(d.scheduled_date))
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [dpps])
|
||||||
|
|
||||||
const filteredDpps = dpps.filter(dpp => {
|
const filteredDpps = dpps.filter(dpp => {
|
||||||
// Only show PDF DPPs here
|
// Only show PDF DPPs here
|
||||||
if (!dpp.pdf_url) return false;
|
if (!dpp.pdf_url) return false;
|
||||||
|
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
if (!dpp.scheduled_date) return false;
|
if (!dpp.scheduled_date) return false;
|
||||||
const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0];
|
const scheduledDate = getLocalDateString(dpp.scheduled_date);
|
||||||
if (scheduledDate !== dateFilter) return false;
|
if (scheduledDate !== dateFilter) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' });
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPdfUrl = (title: string) => {
|
const getPdfUrl = (title: string) => {
|
||||||
@@ -122,33 +145,39 @@ export default function AIDailyPracticePage() {
|
|||||||
Daily Practice (DPP)
|
Daily Practice (DPP)
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
<p className="text-muted-foreground text-base font-medium max-w-2xl pl-1">
|
||||||
Launch Daily Practice Problems (DPPs) tailored to your exam pattern.
|
Launch Daily Practice Problems (DPPs) tailored to your exam pattern with time-bound calendar schedules.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 border-b border-border pb-6">
|
{/* Calendar Filter & Controller Bar */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-border pb-6">
|
||||||
<input
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
type="date"
|
<CalendarFilter
|
||||||
value={dateFilter}
|
selectedDate={dateFilter}
|
||||||
onChange={(e) => setDateFilter(e.target.value)}
|
onSelectDate={setDateFilter}
|
||||||
className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2"
|
availableDates={scheduledDates}
|
||||||
|
placeholder="Filter by Schedule Date"
|
||||||
/>
|
/>
|
||||||
|
{dateFilter && (
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground bg-secondary/60 px-2.5 py-1 rounded-lg">
|
||||||
|
Showing DPPs for {dateFilter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-bold text-muted-foreground">
|
||||||
|
{filteredDpps.length} {filteredDpps.length === 1 ? "Practice Sheet" : "Practice Sheets"} Available
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" className="gap-2 rounded-xl text-sm font-bold">
|
|
||||||
<Download size={16} /> Export as PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
|
<div className="col-span-2 text-center py-12 text-muted-foreground">Loading AI-generated DPPs...</div>
|
||||||
) : filteredDpps.length === 0 ? (
|
) : filteredDpps.length === 0 ? (
|
||||||
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">No DPPs available for the selected criteria.</div>
|
<div className="col-span-2 text-center py-12 text-muted-foreground border border-dashed rounded-xl">
|
||||||
|
No DPPs found for the selected schedule criteria.
|
||||||
|
</div>
|
||||||
) : filteredDpps.map((dpp, i) => {
|
) : filteredDpps.map((dpp, i) => {
|
||||||
// Calculate total questions dynamically from sections
|
// Calculate total questions dynamically from sections
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -159,22 +188,32 @@ export default function AIDailyPracticePage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Date logic
|
// Accurate Date & Time logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now;
|
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : null;
|
||||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
||||||
const isUpcoming = scheduledTime > now;
|
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
|
const isLive = !isUpcoming && !isExpired;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-70 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-80' : 'hover:border-[var(--cyan-ai-10)] bg-background'}`}>
|
<Card key={dpp.id} className={`group cursor-pointer transition-all hover:shadow-md ${isAttempted ? 'opacity-85 hover:opacity-100 bg-secondary/10 border-primary/20' : isExpired ? 'opacity-70' : 'hover:border-[var(--cyan-ai-20)] bg-background'}`}>
|
||||||
<CardContent className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<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="flex gap-4 items-start w-full">
|
||||||
<div className={`mt-1 flex-shrink-0 ${isAttempted ? 'text-green-500' : 'text-foreground'}`}>
|
{/* Clean Status Icon — No Selectable Circle Checkboxes */}
|
||||||
{isAttempted ? <CheckCircle2 size={24} /> : <div className="size-6 rounded-full border-2 border-muted-foreground flex items-center justify-center opacity-50"></div>}
|
<div className="mt-1 flex-shrink-0">
|
||||||
|
{isAttempted ? (
|
||||||
|
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20" title="Completed">
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1.5 rounded-lg bg-secondary/60 text-[var(--cyan-ai)] border border-border" title="Practice Sheet">
|
||||||
|
<FileText size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--cyan-ai)]">
|
||||||
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
{user?.target_exam?.replace("_", " ") || "JEE/NEET"}
|
||||||
</span>
|
</span>
|
||||||
@@ -183,14 +222,30 @@ export default function AIDailyPracticePage() {
|
|||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-yellow-500 bg-yellow-500/10 px-1.5 py-0.5 rounded">Upcoming</span>
|
<span className="text-[10px] font-bold uppercase text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||||
|
🔒 Upcoming
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground text-xs font-bold">•</span>
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">Expired</span>
|
<span className="text-[10px] font-bold uppercase text-muted-foreground bg-secondary px-2 py-0.5 rounded-md">
|
||||||
|
⏳ Expired
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground text-xs font-bold">•</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase text-emerald-500 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-md">
|
||||||
|
🟢 Live Now
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAttempted && (
|
||||||
|
<span className="text-[10px] font-bold uppercase text-green-500 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-md">
|
||||||
|
Attempted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
<h3 className="font-bold text-lg leading-tight group-hover:text-[var(--cyan-ai)] transition-colors">
|
||||||
{cleanTitle(dpp.title)}
|
{cleanTitle(dpp.title)}
|
||||||
@@ -201,10 +256,25 @@ export default function AIDailyPracticePage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
<div className="text-sm text-muted-foreground mt-2 font-medium">
|
||||||
{count} Questions • {dpp.total_time} mins
|
<span>{count} Questions • {dpp.total_time} mins</span>
|
||||||
{dpp.scheduled_date && (
|
{isUpcoming && scheduledTime && (
|
||||||
<span className="ml-2 border-l border-border pl-2">
|
<span className="ml-2 border-l border-border pl-2 text-amber-500 font-semibold">
|
||||||
{isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'}
|
Opens on {formatDateTime(dpp.scheduled_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpired && endTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-destructive font-semibold">
|
||||||
|
Expired on {formatDateTime(dpp.end_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLive && endTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
||||||
|
Closes on {formatDateTime(dpp.end_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLive && !endTime && scheduledTime && (
|
||||||
|
<span className="ml-2 border-l border-border pl-2 text-emerald-500 font-semibold">
|
||||||
|
Available since {formatDateTime(dpp.scheduled_date)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -212,9 +282,13 @@ export default function AIDailyPracticePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
<div className="flex flex-col sm:flex-row gap-2 shrink-0 md:self-center">
|
||||||
{isUpcoming ? (
|
{isUpcoming ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold">Starts Soon</Button>
|
<Button disabled variant="outline" size="sm" className="font-bold text-amber-500 border-amber-500/30 bg-amber-500/5">
|
||||||
|
Opens at {scheduledTime ? formatTime(dpp.scheduled_date) : "Scheduled Time"}
|
||||||
|
</Button>
|
||||||
) : isExpired && !isAttempted ? (
|
) : isExpired && !isAttempted ? (
|
||||||
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50">Expired</Button>
|
<Button disabled variant="outline" size="sm" className="font-bold bg-secondary/50 text-muted-foreground">
|
||||||
|
Expired
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
hasAccessTo('PRO') ? (
|
hasAccessTo('PRO') ? (
|
||||||
<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)]">
|
<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)]">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"
|
|||||||
import { TierBadge } from "@/components/TierBadge"
|
import { TierBadge } from "@/components/TierBadge"
|
||||||
import { StreakCounter } from "@/components/StreakCounter"
|
import { StreakCounter } from "@/components/StreakCounter"
|
||||||
import { AITutorChat } from "@/components/AI/AITutorChat"
|
import { AITutorChat } from "@/components/AI/AITutorChat"
|
||||||
import { useActiveUser, DEMO_USERS, isDemoUser } from "@/hooks/useActiveUser"
|
import { useActiveUser, DEMO_USERS, isDemoUser, isDemoAccount } from "@/hooks/useActiveUser"
|
||||||
import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal"
|
import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
import MaintenanceScreen from "@/components/MaintenanceScreen"
|
||||||
@@ -322,7 +322,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
<Link href="/dashboard/settings/password" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
<Link href="/dashboard/settings/password" className="flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors">
|
||||||
<Lock size={16} /> Change Password
|
<Lock size={16} /> Change Password
|
||||||
</Link>
|
</Link>
|
||||||
{isDemoUser(userId) && (
|
{isDemoAccount(userId) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => switchUser(userId, true)}
|
onClick={() => switchUser(userId, true)}
|
||||||
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors"
|
className="w-full text-left flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-secondary rounded-lg transition-colors"
|
||||||
@@ -391,7 +391,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
||||||
{isDemoUser(userId) && (
|
{isDemoAccount(userId) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{switching && (
|
{switching && (
|
||||||
<span className="text-xs text-[var(--cyan-ai)] font-bold animate-pulse hidden lg:inline">Switching…</span>
|
<span className="text-xs text-[var(--cyan-ai)] font-bold animate-pulse hidden lg:inline">Switching…</span>
|
||||||
@@ -430,7 +430,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
{/* Scrollable page body */}
|
{/* Scrollable page body */}
|
||||||
<main className="flex-1 overflow-y-auto relative bg-background/50">
|
<main className="flex-1 overflow-y-auto relative bg-background/50">
|
||||||
<div key={pathname} className="w-full h-full">
|
<div key={pathname} className="w-full h-full animate-in fade-in zoom-in-95 origin-top duration-500">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -142,17 +142,27 @@ export default function ProgressTrackerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const targetExam = user?.target_exam || "JEE_Main";
|
const targetExam = user?.target_exam || "JEE_Main";
|
||||||
const isNeet = targetExam === "NEET";
|
const isNeet = targetExam === "NEET";
|
||||||
|
const isJeeNeet = targetExam === "JEE_NEET";
|
||||||
|
|
||||||
const mathDemoSubject: Subject = {
|
const mathDemoSubject: Subject = {
|
||||||
name: "Mathematics",
|
name: "Mathematics",
|
||||||
completionPct: 22,
|
completionPct: 28,
|
||||||
chapters: [
|
chapters: [
|
||||||
{
|
{
|
||||||
name: "Calculus",
|
name: "Calculus",
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
topics: [
|
topics: [
|
||||||
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
{ name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 },
|
||||||
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Derivatives", status: "Practiced", accuracy: 72, questionsAttempted: 35 },
|
||||||
|
{ name: "Integrals", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Coordinate Geometry",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Straight Lines", status: "Practiced", accuracy: 80, questionsAttempted: 40 },
|
||||||
|
{ name: "Circles", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -169,6 +179,14 @@ export default function ProgressTrackerPage() {
|
|||||||
{ name: "Cell Wall", status: "Practiced", accuracy: 80, questionsAttempted: 60 },
|
{ name: "Cell Wall", status: "Practiced", accuracy: 80, questionsAttempted: 60 },
|
||||||
{ name: "Nucleus", status: "In Progress", accuracy: 50, questionsAttempted: 25 },
|
{ name: "Nucleus", status: "In Progress", accuracy: 50, questionsAttempted: 25 },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Genetics & Evolution",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Mendelian Genetics", status: "Practiced", accuracy: 85, questionsAttempted: 55 },
|
||||||
|
{ name: "Molecular Basis of Inheritance", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -183,6 +201,15 @@ export default function ProgressTrackerPage() {
|
|||||||
topics: [
|
topics: [
|
||||||
{ name: "Limits", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Limits", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
{ name: "Integrals", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Coordinate Geometry",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Straight Lines", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
{ name: "Circles", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -199,12 +226,29 @@ export default function ProgressTrackerPage() {
|
|||||||
{ name: "Cell Wall", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Cell Wall", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
{ name: "Nucleus", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
{ name: "Nucleus", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Genetics & Evolution",
|
||||||
|
isExpanded: false,
|
||||||
|
topics: [
|
||||||
|
{ name: "Mendelian Genetics", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
{ name: "Molecular Basis of Inheritance", status: "Not Started", accuracy: 0, questionsAttempted: 0 },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const base = isDemo ? [...DEMO_SUBJECTS] : [...ZERO_SUBJECTS];
|
const base = isDemo ? [...DEMO_SUBJECTS] : [...ZERO_SUBJECTS];
|
||||||
base.push(isDemo ? (isNeet ? bioDemoSubject : mathDemoSubject) : (isNeet ? bioZeroSubject : mathZeroSubject));
|
|
||||||
|
if (isJeeNeet) {
|
||||||
|
// 4 Subjects for JEE + NEET: Physics, Chemistry, Mathematics, Biology
|
||||||
|
base.push(isDemo ? mathDemoSubject : mathZeroSubject);
|
||||||
|
base.push(isDemo ? bioDemoSubject : bioZeroSubject);
|
||||||
|
} else if (isNeet) {
|
||||||
|
base.push(isDemo ? bioDemoSubject : bioZeroSubject);
|
||||||
|
} else {
|
||||||
|
base.push(isDemo ? mathDemoSubject : mathZeroSubject);
|
||||||
|
}
|
||||||
|
|
||||||
setSubjects(base);
|
setSubjects(base);
|
||||||
}, [isDemo, user?.target_exam]);
|
}, [isDemo, user?.target_exam]);
|
||||||
@@ -232,7 +276,7 @@ export default function ProgressTrackerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div className={`grid grid-cols-1 sm:grid-cols-2 ${subjects.length >= 4 ? 'lg:grid-cols-4' : 'md:grid-cols-3'} gap-6 mb-8`}>
|
||||||
{subjects.map((sub, i) => (
|
{subjects.map((sub, i) => (
|
||||||
<Card key={i} className="border-border shadow-sm">
|
<Card key={i} className="border-border shadow-sm">
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
|
|||||||
321
src/components/CalendarFilter.tsx
Normal file
321
src/components/CalendarFilter.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react"
|
||||||
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Sparkles, Check } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export interface CalendarFilterProps {
|
||||||
|
selectedDate: string // Format: "YYYY-MM-DD" or ""
|
||||||
|
onSelectDate: (date: string) => void
|
||||||
|
availableDates?: string[] // Optional array of "YYYY-MM-DD"
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
align?: "left" | "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to format any date safely in local time as "YYYY-MM-DD" */
|
||||||
|
export function getLocalDateString(dateInput: string | Date | null | undefined): string {
|
||||||
|
if (!dateInput) return ""
|
||||||
|
const d = typeof dateInput === "string" ? new Date(dateInput) : dateInput
|
||||||
|
if (isNaN(d.getTime())) return ""
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(d.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarFilter({
|
||||||
|
selectedDate,
|
||||||
|
onSelectDate,
|
||||||
|
availableDates = [],
|
||||||
|
placeholder = "Filter by Date",
|
||||||
|
className = "",
|
||||||
|
align = "left",
|
||||||
|
}: CalendarFilterProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Current view year & month for calendar navigation
|
||||||
|
const today = new Date()
|
||||||
|
const [viewYear, setViewYear] = useState(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const parts = selectedDate.split("-").map(Number)
|
||||||
|
if (parts[0]) return parts[0]
|
||||||
|
}
|
||||||
|
return today.getFullYear()
|
||||||
|
})
|
||||||
|
|
||||||
|
const [viewMonth, setViewMonth] = useState(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const parts = selectedDate.split("-").map(Number)
|
||||||
|
if (parts[1]) return parts[1] - 1
|
||||||
|
}
|
||||||
|
return today.getMonth()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync view when selectedDate changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const parts = selectedDate.split("-").map(Number)
|
||||||
|
if (parts[0] && parts[1]) {
|
||||||
|
setViewYear(parts[0])
|
||||||
|
setViewMonth(parts[1] - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
// Click outside listener
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const prevMonth = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (viewMonth === 0) {
|
||||||
|
setViewMonth(11)
|
||||||
|
setViewYear((y) => y - 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((m) => m - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (viewMonth === 11) {
|
||||||
|
setViewMonth(0)
|
||||||
|
setViewYear((y) => y + 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((m) => m + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectDay = (day: number) => {
|
||||||
|
const formatted = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||||
|
if (selectedDate === formatted) {
|
||||||
|
onSelectDate("")
|
||||||
|
} else {
|
||||||
|
onSelectDate(formatted)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setQuickDate = (dateStr: string) => {
|
||||||
|
onSelectDate(dateStr)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days calculations
|
||||||
|
const firstDayOfWeek = new Date(viewYear, viewMonth, 1).getDay() // 0 = Sun
|
||||||
|
const totalDaysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
||||||
|
const monthNames = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
]
|
||||||
|
|
||||||
|
const todayStr = getLocalDateString(today)
|
||||||
|
const tomorrowStr = getLocalDateString(new Date(Date.now() + 86400000))
|
||||||
|
|
||||||
|
const formatDisplay = (dateStr: string) => {
|
||||||
|
if (!dateStr) return placeholder
|
||||||
|
const parts = dateStr.split("-").map(Number)
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const d = new Date(parts[0], parts[1] - 1, parts[2])
|
||||||
|
if (dateStr === todayStr) {
|
||||||
|
return `Today (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})`
|
||||||
|
}
|
||||||
|
if (dateStr === tomorrowStr) {
|
||||||
|
return `Tomorrow (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})`
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available dates set for quick lookup
|
||||||
|
const availableSet = new Set(availableDates.map((d) => d.split("T")[0]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block ${className}`} ref={containerRef}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all border shadow-sm ${
|
||||||
|
selectedDate
|
||||||
|
? "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] border-[var(--cyan-ai-30)] shadow-[0_0_12px_rgba(12,96,96,0.15)]"
|
||||||
|
: "bg-background text-muted-foreground border-border hover:border-primary/40 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-1 rounded-md bg-secondary/80 text-[var(--cyan-ai)]">
|
||||||
|
<CalendarIcon size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">{formatDisplay(selectedDate)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onSelectDate("")
|
||||||
|
}}
|
||||||
|
title="Clear date filter"
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popover Calendar */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={`absolute ${
|
||||||
|
align === "right" ? "right-0" : "left-0"
|
||||||
|
} top-full mt-2 w-72 sm:w-80 p-4 bg-card border border-border/80 rounded-2xl shadow-2xl shadow-black/30 z-50 animate-in fade-in zoom-in-95 origin-top duration-200 backdrop-blur-xl`}
|
||||||
|
>
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div className="flex items-center gap-1 mb-3.5 pb-3 border-b border-border/60 overflow-x-auto custom-scrollbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuickDate("")}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
||||||
|
!selectedDate
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Dates
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuickDate(todayStr)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
||||||
|
selectedDate === todayStr
|
||||||
|
? "bg-[var(--cyan-ai)] text-black"
|
||||||
|
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuickDate(tomorrowStr)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-bold shrink-0 transition-colors ${
|
||||||
|
selectedDate === tomorrowStr
|
||||||
|
? "bg-[var(--cyan-ai)] text-black"
|
||||||
|
: "bg-secondary/60 text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tomorrow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month / Year Navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<h4 className="font-extrabold text-sm text-foreground">
|
||||||
|
{monthNames[viewMonth]} {viewYear}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevMonth}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Previous Month"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextMonth}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Next Month"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days of Week Header */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
||||||
|
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||||
|
<span key={d} className="text-[10px] font-black uppercase text-muted-foreground/70 py-1">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days Grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-8 w-8" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from({ length: totalDaysInMonth }).map((_, i) => {
|
||||||
|
const day = i + 1
|
||||||
|
const currentDayStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||||
|
const isSelected = selectedDate === currentDayStr
|
||||||
|
const isToday = currentDayStr === todayStr
|
||||||
|
const hasItems = availableSet.has(currentDayStr)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectDay(day)}
|
||||||
|
className={`h-8 w-8 mx-auto rounded-lg text-xs font-bold relative flex flex-col items-center justify-center transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--cyan-ai)] text-black font-black shadow-md scale-105"
|
||||||
|
: isToday
|
||||||
|
? "border border-[var(--cyan-ai)] text-[var(--cyan-ai)] hover:bg-secondary"
|
||||||
|
: "text-foreground hover:bg-secondary/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{day}</span>
|
||||||
|
{hasItems && (
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-0.5 w-1 h-1 rounded-full ${
|
||||||
|
isSelected ? "bg-black" : "bg-[var(--cyan-ai)] shadow-[0_0_4px_var(--cyan-ai)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer stats if availableDates exists */}
|
||||||
|
{availableDates.length > 0 && (
|
||||||
|
<div className="mt-3.5 pt-2.5 border-t border-border/50 flex items-center justify-between text-[11px] text-muted-foreground px-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--cyan-ai)] inline-block"></span>
|
||||||
|
Scheduled Tests Available
|
||||||
|
</span>
|
||||||
|
{selectedDate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectDate("")
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className="font-bold text-[var(--cyan-ai)] hover:underline"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,17 +28,30 @@ export const DEMO_USERS = {
|
|||||||
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" },
|
AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" },
|
||||||
AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "AI Coach - NEET" },
|
AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "AI Coach - NEET" },
|
||||||
AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "AI Coach - JEE+NEET" },
|
AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "AI Coach - JEE+NEET" },
|
||||||
FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE" },
|
FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE (New/Raw)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// IDs considered "demo" accounts — demo switcher only shows for these
|
// Showcase accounts that have pre-filled mock activity data for demonstration
|
||||||
|
export const SHOWCASE_DEMO_IDS = new Set([
|
||||||
|
DEMO_USERS.AICOACH_JEE.id,
|
||||||
|
DEMO_USERS.AICOACH_NEET.id,
|
||||||
|
DEMO_USERS.AICOACH_JEE_NEET.id,
|
||||||
|
"usr_01", "usr_02", "usr_03"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// IDs allowed in the demo switcher bar
|
||||||
export const DEMO_IDS = new Set([
|
export const DEMO_IDS = new Set([
|
||||||
...Object.values(DEMO_USERS).map(u => u.id),
|
...Object.values(DEMO_USERS).map(u => u.id),
|
||||||
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
"usr_01", "usr_02", "usr_03", "usr_04", "usr_05"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Returns true if the userId matches a known demo account */
|
/** Returns true if the userId is a showcase account with pre-filled mock activity data */
|
||||||
export function isDemoUser(userId?: string | null): boolean {
|
export function isDemoUser(userId?: string | null): boolean {
|
||||||
|
return !!userId && SHOWCASE_DEMO_IDS.has(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the userId is eligible for demo switcher dropdown */
|
||||||
|
export function isDemoAccount(userId?: string | null): boolean {
|
||||||
return !!userId && DEMO_IDS.has(userId);
|
return !!userId && DEMO_IDS.has(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user