| |
| import React, { useEffect, useState } from "react"; |
| import { useNavigate, useSearchParams } from "react-router-dom"; |
| import { |
| Menu, |
| Calendar, |
| Clock, |
| MapPin, |
| LayoutDashboard, |
| Users as UsersIcon, |
| FileText, |
| Trophy, |
| ShoppingBag, |
| DollarSign, |
| CheckCircle2, |
| AlertCircle, |
| ArrowRight, |
| CreditCard, |
| } from "lucide-react"; |
| import { format } from "date-fns"; |
| import api from "../api/client"; |
| import UserMenu from "../components/UserMenu"; |
| import dojoLogo from "../assets/dojo-logo.png"; |
|
|
| export default function StudentExams() { |
| const navigate = useNavigate(); |
| const [searchParams, setSearchParams] = useSearchParams(); |
|
|
| const stored = JSON.parse(localStorage.getItem("karateStudent") || "{}"); |
| const studentName = stored.name || "Student"; |
| const studentEmail = stored.email || "student1@example.com"; |
|
|
| const [registrations, setRegistrations] = useState([]); |
| const [loading, setLoading] = useState(true); |
| const [mobileNavOpen, setMobileNavOpen] = useState(false); |
| const [paymentStatus, setPaymentStatus] = useState(null); |
|
|
| |
| useEffect(() => { |
| const payment = searchParams.get("payment"); |
| if (payment === "success") { |
| setPaymentStatus("success"); |
| setTimeout(() => { |
| setSearchParams({}); |
| window.location.reload(); |
| }, 2000); |
| } else if (payment === "cancelled") { |
| setPaymentStatus("cancelled"); |
| setSearchParams({}); |
| setTimeout(() => setPaymentStatus(null), 5000); |
| } |
| }, [searchParams, setSearchParams]); |
|
|
| useEffect(() => { |
| if (!studentEmail) return; |
|
|
| async function loadData() { |
| setLoading(true); |
| try { |
| const res = await api.get("/student/exam-registrations", { |
| params: { email: studentEmail }, |
| }); |
| setRegistrations(Array.isArray(res.data) ? res.data : []); |
| } catch (err) { |
| console.error("Error loading exam registrations:", err); |
| setRegistrations([]); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| loadData(); |
| }, [studentEmail]); |
|
|
| const handlePayment = async (registration) => { |
| try { |
| |
| const res = await api.post(`/exam/${registration.id}/checkout`); |
| if (res.data?.checkout_url) { |
| |
| window.location.href = res.data.checkout_url; |
| } else { |
| alert("Failed to create payment session. Please try again."); |
| } |
| } catch (err) { |
| console.error("Error creating checkout session:", err); |
| alert(err.response?.data?.detail || "Failed to create payment session. Please try again."); |
| } |
| }; |
|
|
| function handleLogout() { |
| localStorage.removeItem("karateStudent"); |
| navigate("/login"); |
| } |
|
|
| const navItems = [ |
| { label: "Dashboard", to: "/student", icon: LayoutDashboard }, |
| { label: "Classes", to: "/student/classes", icon: UsersIcon }, |
| { label: "Exams", to: "/student/exams", icon: FileText }, |
| { label: "Competitions", to: "/student/competitions", icon: Trophy }, |
| { label: "Shop", to: "/student/products", icon: ShoppingBag }, |
| ]; |
|
|
| const handleNavClick = (to) => { |
| navigate(to); |
| setMobileNavOpen(false); |
| }; |
|
|
| const isActivePath = (to) => { |
| if (typeof window === "undefined") return false; |
| return window.location.pathname === to; |
| }; |
|
|
| |
| const pendingExams = registrations.filter( |
| (r) => r.payment_status === "unpaid" || r.payment_status === "pending" || r.registration_status === "invited" |
| ); |
|
|
| return ( |
| <div className="min-h-screen bg-stone-50 flex flex-col"> |
| {/* TOP HEADER */} |
| <header className="border-b border-stone-100 bg-white flex items-center justify-between px-4 sm:px-6 lg:px-10 py-3"> |
| <div className="flex items-center gap-3"> |
| <button |
| type="button" |
| className="inline-flex md:hidden items-center justify-center h-9 w-9 rounded-full border border-stone-200 text-stone-700 hover:bg-stone-50" |
| onClick={() => setMobileNavOpen(true)} |
| aria-label="Open navigation" |
| > |
| <Menu className="w-4 h-4" /> |
| </button> |
| <img |
| src={dojoLogo} |
| alt="Dojo logo" |
| className="h-9 w-9 rounded-full border border-stone-200 bg-white object-cover" |
| /> |
| <div className="hidden sm:block"> |
| <div className="text-sm font-semibold text-stone-900">Karate Dojo</div> |
| <div className="text-xs text-stone-500">Student Portal</div> |
| </div> |
| </div> |
| <UserMenu name={studentName} email={studentEmail} onLogout={handleLogout} /> |
| </header> |
| |
| {/* BODY: sidebar + main content */} |
| <div className="flex flex-1"> |
| {/* DESKTOP SIDEBAR */} |
| <aside className="hidden md:flex w-64 flex-col bg-white"> |
| <nav className="flex-1 px-3 pt-4 pb-2 space-y-1"> |
| {navItems.map((item) => { |
| const Icon = item.icon; |
| const isActive = isActivePath(item.to); |
| const hasNotification = item.to === "/student/exams" && pendingExams.length > 0; |
| return ( |
| <button |
| key={item.to} |
| type="button" |
| onClick={() => handleNavClick(item.to)} |
| className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left relative ${ |
| isActive |
| ? "bg-rose-50 text-rose-700 font-semibold" |
| : "text-stone-600 hover:bg-stone-50" |
| }`} |
| > |
| <span |
| className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${ |
| isActive |
| ? "border-rose-100 bg-rose-50 text-rose-600" |
| : "border-stone-200 bg-white text-stone-500" |
| }`} |
| > |
| <Icon className="w-4 h-4" /> |
| </span> |
| <span>{item.label}</span> |
| {hasNotification && ( |
| <span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white text-xs font-bold"> |
| {pendingExams.length} |
| </span> |
| )} |
| </button> |
| ); |
| })} |
| </nav> |
| <div className="px-4 pb-5"> |
| <div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2"> |
| <div className="text-[11px] font-semibold text-rose-700">Student Mode</div> |
| <div className="text-[11px] text-rose-500">Limited access view</div> |
| </div> |
| </div> |
| </aside> |
| |
| {/* MAIN CONTENT */} |
| <div className="flex-1 border-l border-stone-100"> |
| <main className="px-4 sm:px-6 lg:px-10 py-6 sm:py-8 pb-10"> |
| {/* Header */} |
| <div className="mb-8"> |
| <h1 className="text-2xl sm:text-3xl font-bold text-stone-900">Exams</h1> |
| <p className="text-stone-600 mt-1">View and manage your exam registrations</p> |
| </div> |
| |
| {/* Payment Status Messages */} |
| {paymentStatus === "success" && ( |
| <div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2"> |
| <CheckCircle2 className="w-5 h-5 text-green-600" /> |
| <p className="text-sm text-green-800">Payment successful! Your registration is being processed.</p> |
| </div> |
| )} |
| {paymentStatus === "cancelled" && ( |
| <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"> |
| <AlertCircle className="w-5 h-5 text-amber-600" /> |
| <p className="text-sm text-amber-800">Payment was cancelled. You can try again when ready.</p> |
| </div> |
| )} |
| |
| {/* Exams List */} |
| {loading ? ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm"> |
| <div className="px-4 sm:px-6 py-12 text-center text-stone-500"> |
| <p>Loading exams...</p> |
| </div> |
| </div> |
| ) : registrations.length === 0 ? ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm"> |
| <div className="px-4 sm:px-6 py-12 text-center text-stone-500"> |
| <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" /> |
| <p>No exam registrations found</p> |
| </div> |
| </div> |
| ) : ( |
| <div className="space-y-4"> |
| {registrations.map((reg) => { |
| const isPending = reg.payment_status === "unpaid" || reg.registration_status === "invited"; |
| const isPaid = reg.payment_status === "paid"; |
| const exam = reg.exam || {}; |
| const batch = reg.batch || {}; |
| |
| return ( |
| <div |
| key={reg.id} |
| className={`bg-white border-2 rounded-2xl shadow-sm overflow-hidden ${ |
| isPending ? "border-amber-200 bg-gradient-to-br from-amber-50 to-white" : "border-stone-200" |
| }`} |
| > |
| <div className="p-5 sm:p-6"> |
| <div className="flex flex-col sm:flex-row justify-between items-start gap-4"> |
| <div className="flex-1"> |
| <div className="flex items-center gap-2 mb-2"> |
| <h3 className="text-xl font-bold text-stone-900">{exam.name || "Exam"}</h3> |
| {isPending && ( |
| <span className="inline-flex items-center rounded-full bg-amber-100 text-amber-700 text-xs font-medium px-2 py-0.5"> |
| Pending Payment |
| </span> |
| )} |
| {isPaid && ( |
| <span className="inline-flex items-center rounded-full bg-green-100 text-green-700 text-xs font-medium px-2 py-0.5"> |
| Registered |
| </span> |
| )} |
| </div> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4 text-sm text-stone-600"> |
| {exam.exam_date && ( |
| <div className="flex items-center gap-2"> |
| <Calendar className="w-4 h-4" /> |
| <span>{format(new Date(exam.exam_date), "MMM d, yyyy")}</span> |
| </div> |
| )} |
| {batch.time_from && batch.time_to && ( |
| <div className="flex items-center gap-2"> |
| <Clock className="w-4 h-4" /> |
| <span> |
| {batch.batch_name} • {batch.time_from.substring(0, 5)} - {batch.time_to.substring(0, 5)} |
| </span> |
| </div> |
| )} |
| {exam.location && ( |
| <div className="flex items-center gap-2"> |
| <MapPin className="w-4 h-4" /> |
| <span>{exam.location}</span> |
| </div> |
| )} |
| <div className="flex items-center gap-2"> |
| <DollarSign className="w-4 h-4" /> |
| <span className="font-medium">${exam.exam_fee || 0}</span> |
| </div> |
| </div> |
| |
| {exam.description && ( |
| <div className="mb-4 p-3 bg-stone-50 rounded-lg text-sm text-stone-700"> |
| {exam.description} |
| </div> |
| )} |
| |
| <div className="text-sm text-stone-600"> |
| <div className="mb-1"> |
| <span className="font-medium">Student:</span> {reg.student_name} |
| </div> |
| <div> |
| <span className="font-medium">Target Level:</span>{" "} |
| <span className="text-blue-600 font-medium">{reg.target_level || "N/A"}</span> |
| </div> |
| {reg.current_level && ( |
| <div> |
| <span className="font-medium">Current Level:</span> {reg.current_level} |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <div className="flex flex-col gap-2 w-full sm:w-auto"> |
| {isPending && ( |
| <button |
| type="button" |
| onClick={() => handlePayment(reg)} |
| className="inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-6 py-2.5 shadow-sm" |
| > |
| <CreditCard className="w-4 h-4" /> |
| Register and Pay |
| </button> |
| )} |
| {isPaid && ( |
| <div className="inline-flex items-center gap-2 rounded-lg bg-green-50 text-green-700 text-sm font-medium px-6 py-2.5"> |
| <CheckCircle2 className="w-4 h-4" /> |
| Payment Complete |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </main> |
| </div> |
| </div> |
| |
| {/* MOBILE DRAWER NAV */} |
| {mobileNavOpen && ( |
| <> |
| <div |
| className="fixed inset-0 z-40 bg-black/20 md:hidden" |
| onClick={() => setMobileNavOpen(false)} |
| /> |
| <div className="fixed inset-y-0 left-0 z-50 w-72 max-w-full bg-white border-r border-stone-100 shadow-lg flex flex-col md:hidden"> |
| <div className="px-5 pt-5 pb-4 flex items-center gap-3 border-b border-stone-100"> |
| <img |
| src={dojoLogo} |
| alt="Dojo logo" |
| className="h-9 w-9 rounded-full border border-stone-200 bg-white object-cover" |
| /> |
| <div> |
| <div className="text-sm font-semibold text-stone-900">Karate Dojo</div> |
| <div className="text-xs text-stone-500">Student Portal</div> |
| </div> |
| </div> |
| <nav className="flex-1 px-3 pt-4 pb-2 space-y-1 overflow-y-auto"> |
| {navItems.map((item) => { |
| const Icon = item.icon; |
| const isActive = isActivePath(item.to); |
| const hasNotification = item.to === "/student/exams" && pendingExams.length > 0; |
| return ( |
| <button |
| key={item.to} |
| type="button" |
| onClick={() => handleNavClick(item.to)} |
| className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left relative ${ |
| isActive |
| ? "bg-rose-50 text-rose-700 font-semibold" |
| : "text-stone-600 hover:bg-stone-50" |
| }`} |
| > |
| <span |
| className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${ |
| isActive |
| ? "border-rose-100 bg-rose-50 text-rose-600" |
| : "border-stone-200 bg-white text-stone-500" |
| }`} |
| > |
| <Icon className="w-4 h-4" /> |
| </span> |
| <span>{item.label}</span> |
| {hasNotification && ( |
| <span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white text-xs font-bold"> |
| {pendingExams.length} |
| </span> |
| )} |
| </button> |
| ); |
| })} |
| </nav> |
| <div className="px-4 pb-5"> |
| <div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2"> |
| <div className="text-[11px] font-semibold text-rose-700">Student Mode</div> |
| <div className="text-[11px] text-rose-500">Limited access view</div> |
| </div> |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| ); |
| } |
|
|
|
|