| |
| import React, { useEffect, useState } from "react"; |
| import { useNavigate, useSearchParams } from "react-router-dom"; |
| import { |
| Menu, |
| MessageCircle, |
| Calendar, |
| Users, |
| Clock, |
| LayoutDashboard, |
| Users as UsersIcon, |
| FileText, |
| Trophy, |
| ShoppingBag, |
| X, |
| CreditCard, |
| CheckCircle2, |
| ArrowRight, |
| Plus, |
| TrendingUp, |
| } from "lucide-react"; |
| import { format, differenceInDays, differenceInYears } from "date-fns"; |
| import api from "../api/client"; |
| import UserMenu from "../components/UserMenu"; |
| import StudentAIAssistant from "../components/student/StudentAIAssistant"; |
| import InviteAcceptance from "./InviteAcceptance"; |
| import PlanAcceptance from "./PlanAcceptance"; |
| import dojoLogo from "../assets/dojo-logo.png"; |
|
|
| export default function StudentDashboard() { |
| 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 firstName = studentName.split(" ")[0]; |
|
|
| const [plans, setPlans] = useState([]); |
| const [memberships, setMemberships] = useState([]); |
| const [pendingInvites, setPendingInvites] = useState([]); |
| const [requests, setRequests] = useState([]); |
| const [myStudents, setMyStudents] = useState([]); |
| const [enrollments, setEnrollments] = useState([]); |
| const [examRegistrations, setExamRegistrations] = useState([]); |
| const [loading, setLoading] = useState(true); |
| const [aiOpen, setAiOpen] = useState(false); |
| const [mobileNavOpen, setMobileNavOpen] = useState(false); |
| const [selectedInvite, setSelectedInvite] = useState(null); |
| const [selectedPlan, setSelectedPlan] = useState(null); |
| const [paymentStatus, setPaymentStatus] = useState(null); |
| const [showAddStudent, setShowAddStudent] = useState(false); |
| const [showManageMembership, setShowManageMembership] = useState(false); |
| const [showPauseConfirm, setShowPauseConfirm] = useState(false); |
| const [showCancelConfirm, setShowCancelConfirm] = useState(false); |
| const [pauseMessage, setPauseMessage] = useState(null); |
| const [cancelMessage, setCancelMessage] = useState(null); |
| const [isPausing, setIsPausing] = useState(false); |
| const [isCancelling, setIsCancelling] = useState(false); |
| const [withdrawPauseMessage, setWithdrawPauseMessage] = useState(null); |
| const [withdrawCancelMessage, setWithdrawCancelMessage] = useState(null); |
| const [resumeMessage, setResumeMessage] = useState(null); |
| const [reactivateMessage, setReactivateMessage] = useState(null); |
| const [studentMessage, setStudentMessage] = useState(null); |
| const [studentForm, setStudentForm] = useState({ |
| first_name: "", |
| last_name: "", |
| date_of_birth: "", |
| gender: "", |
| medical_notes: "", |
| }); |
|
|
| |
| useEffect(() => { |
| const payment = searchParams.get("payment"); |
| const inviteToken = searchParams.get("invite"); |
|
|
| if (payment === "success") { |
| setPaymentStatus("success"); |
| setSelectedInvite(null); |
| setTimeout(() => { |
| setSearchParams({}); |
| window.location.reload(); |
| }, 2000); |
| } else if (payment === "cancelled") { |
| setPaymentStatus("cancelled"); |
| setSearchParams({}); |
| setTimeout(() => setPaymentStatus(null), 5000); |
| } |
| }, [searchParams, setSearchParams]); |
|
|
| |
| useEffect(() => { |
| if (selectedInvite) { |
| |
| setTimeout(() => { |
| const container = document.getElementById('invite-acceptance-container'); |
| if (container) { |
| container.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| } |
| }, 200); |
| } |
| }, [selectedInvite]); |
|
|
| function handleLogout() { |
| localStorage.removeItem("karateStudent"); |
| navigate("/login"); |
| } |
|
|
| |
| const loadData = React.useCallback(async () => { |
| if (!studentEmail) return; |
| |
| setLoading(true); |
| try { |
| |
| const plansRes = await api.get("/student/plans"); |
| setPlans(Array.isArray(plansRes.data) ? plansRes.data : []); |
|
|
| |
| const membershipsRes = await api.get("/student/memberships", { |
| params: { email: studentEmail }, |
| }); |
| setMemberships(Array.isArray(membershipsRes.data) ? membershipsRes.data : []); |
|
|
| |
| try { |
| const invitesRes = await api.get("/student/invites", { |
| params: { email: studentEmail }, |
| }); |
| setPendingInvites(Array.isArray(invitesRes.data) ? invitesRes.data : []); |
| } catch (err) { |
| setPendingInvites([]); |
| } |
|
|
|
|
| |
| try { |
| const studentsRes = await api.get("/student/students", { |
| params: { email: studentEmail }, |
| }); |
| setMyStudents(Array.isArray(studentsRes.data) ? studentsRes.data : []); |
| } catch (err) { |
| setMyStudents([]); |
| } |
|
|
| |
| try { |
| const enrollmentsRes = await api.get("/student/enrollments", { |
| params: { email: studentEmail }, |
| }); |
| setEnrollments(Array.isArray(enrollmentsRes.data) ? enrollmentsRes.data : []); |
| } catch (err) { |
| setEnrollments([]); |
| } |
|
|
| |
| try { |
| const examRes = await api.get("/student/exam-registrations", { |
| params: { email: studentEmail }, |
| }); |
| setExamRegistrations(Array.isArray(examRes.data) ? examRes.data : []); |
| } catch (err) { |
| setExamRegistrations([]); |
| } |
|
|
| |
| try { |
| const requestsRes = await api.get("/student/requests", { |
| params: { email: studentEmail }, |
| }); |
| setRequests(Array.isArray(requestsRes.data) ? requestsRes.data : []); |
| } catch (err) { |
| setRequests([]); |
| } |
| } finally { |
| setLoading(false); |
| } |
| }, [studentEmail]); |
|
|
| useEffect(() => { |
| loadData(); |
| }, [loadData]); |
|
|
| |
| |
| const today = new Date(); |
| today.setHours(0, 0, 0, 0); |
| |
| const activeMembership = memberships.find((m) => { |
| |
| if (m.is_cancellation_entry || m.is_pause_entry) return false; |
| |
| if (m.status === "active" || m.status === "paused") return true; |
| |
| if (m.status === "cancelled" && m.renewal_date) { |
| const renewalDate = new Date(m.renewal_date); |
| renewalDate.setHours(0, 0, 0, 0); |
| return renewalDate >= today; |
| } |
| return false; |
| }) || null; |
| const daysUntilRenewal = activeMembership?.renewal_date |
| ? differenceInDays(new Date(activeMembership.renewal_date), new Date()) |
| : null; |
|
|
| |
| const pendingPauseRequest = activeMembership ? requests.find( |
| (r) => r.membership_id === activeMembership.id && r.request_type === "pause" && r.status === "pending" |
| ) : null; |
| const pendingCancelRequest = activeMembership ? requests.find( |
| (r) => r.membership_id === activeMembership.id && r.request_type === "cancel" && r.status === "pending" |
| ) : null; |
|
|
| |
| const resumeDate = activeMembership?.status === "paused" && activeMembership?.paused_at |
| ? (() => { |
| const pausedDate = new Date(activeMembership.paused_at); |
| const resume = new Date(pausedDate); |
| resume.setDate(resume.getDate() + 30); |
| return resume; |
| })() |
| : null; |
|
|
| |
| |
| const hasPausedBefore = activeMembership?.paused_at && activeMembership?.renewal_date |
| ? (() => { |
| const renewalDate = new Date(activeMembership.renewal_date); |
| renewalDate.setHours(0, 0, 0, 0); |
| |
| return renewalDate >= today; |
| })() |
| : false; |
|
|
| |
| const canReactivate = activeMembership?.status === "cancelled" && activeMembership?.renewal_date && |
| new Date(activeMembership.renewal_date) >= today; |
|
|
| |
| const activeClassesCount = new Set(enrollments.map((e) => e.class_id)).size; |
|
|
| |
| const pendingExamInvites = examRegistrations.filter( |
| (r) => r.payment_status === "unpaid" || r.payment_status === "pending" || r.registration_status === "invited" |
| ); |
|
|
| |
| const totalPendingInvites = pendingInvites.length + pendingExamInvites.length; |
|
|
| |
| const defaultPlans = plans.filter((p) => p.is_default).slice(0, 2); |
|
|
| |
| const handleSelectPlan = (plan) => { |
| setSelectedPlan(plan); |
| }; |
|
|
| |
| const enrollmentsByClass = enrollments.reduce((acc, e) => { |
| if (!acc[e.class_id]) { |
| acc[e.class_id] = { |
| class_id: e.class_id, |
| class_name: e.class_name, |
| students: [], |
| }; |
| } |
| acc[e.class_id].students.push({ |
| student_id: e.student_id, |
| student_name: e.student_name, |
| }); |
| return acc; |
| }, {}); |
|
|
| |
| const calculateAge = (dob) => { |
| if (!dob) return null; |
| try { |
| return differenceInYears(new Date(), new Date(dob)); |
| } catch { |
| return null; |
| } |
| }; |
|
|
| |
| const examProgress = myStudents.length > 0 |
| ? myStudents.map((student, idx) => ({ |
| student: `${student.first_name} ${student.last_name}`, |
| currentLevel: idx === 0 ? 3 : 1, |
| levelName: idx === 0 ? "Yellow Belt" : "White Belt", |
| nextLevel: idx === 0 ? "Orange Belt" : "Yellow Belt", |
| progress: idx === 0 ? 75 : 40, |
| })) |
| : [ |
| { |
| student: "Alex Johnson", |
| currentLevel: 3, |
| levelName: "Yellow Belt", |
| nextLevel: "Orange Belt", |
| progress: 75, |
| }, |
| { |
| student: "Emma Johnson", |
| currentLevel: 1, |
| levelName: "White Belt", |
| nextLevel: "Yellow Belt", |
| progress: 40, |
| }, |
| ]; |
|
|
| |
| |
| const allNavItems = [ |
| { 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 navItems = activeMembership |
| ? allNavItems |
| : allNavItems.filter(item => item.label === "Dashboard"); |
|
|
| const handleNavClick = (to) => { |
| navigate(to); |
| setMobileNavOpen(false); |
| }; |
|
|
| const isActivePath = (to) => { |
| if (typeof window === "undefined") return false; |
| return window.location.pathname === to; |
| }; |
|
|
| const canAddMoreStudents = |
| activeMembership && myStudents.length < (activeMembership.max_students || 1); |
|
|
| const handleAddStudent = async (e) => { |
| e.preventDefault(); |
| if (!activeMembership) return; |
|
|
| try { |
| await api.post("/admin/students", { |
| membership_id: activeMembership.id, |
| first_name: studentForm.first_name, |
| last_name: studentForm.last_name, |
| date_of_birth: studentForm.date_of_birth || null, |
| gender: studentForm.gender || null, |
| medical_notes: studentForm.medical_notes || null, |
| }); |
| |
| const studentsRes = await api.get("/student/students", { |
| params: { email: studentEmail }, |
| }); |
| setMyStudents(Array.isArray(studentsRes.data) ? studentsRes.data : []); |
| setStudentMessage({ |
| type: "success", |
| text: "Student added successfully!" |
| }); |
| setTimeout(() => { |
| setShowAddStudent(false); |
| setStudentForm({ |
| first_name: "", |
| last_name: "", |
| date_of_birth: "", |
| gender: "", |
| medical_notes: "", |
| }); |
| setStudentMessage(null); |
| }, 1500); |
| } catch (err) { |
| console.error("Failed to add student:", err); |
| setStudentMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to add student. Please try again." |
| }); |
| } |
| }; |
|
|
| 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 object-cover" |
| /> |
| <div className="hidden sm:block"> |
| <div className="text-sm font-semibold text-stone-900">Arun Martial Arts</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" && pendingExamInvites.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"> |
| {pendingExamInvites.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-8 py-6 sm:py-8"> |
| {/* Payment Status Messages */} |
| {paymentStatus === "success" && ( |
| <div className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3"> |
| <div className="flex items-center gap-2"> |
| <div className="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center"> |
| <span className="text-white text-xs font-bold">✓</span> |
| </div> |
| <div> |
| <p className="text-sm font-semibold text-green-900">Payment Successful!</p> |
| <p className="text-xs text-green-700"> |
| Your membership has been activated. Refreshing your dashboard... |
| </p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {paymentStatus === "cancelled" && ( |
| <div className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3"> |
| <div className="flex items-center gap-2"> |
| <div className="w-5 h-5 rounded-full bg-amber-600 flex items-center justify-center"> |
| <span className="text-white text-xs font-bold">!</span> |
| </div> |
| <div> |
| <p className="text-sm font-semibold text-amber-900">Payment Cancelled</p> |
| <p className="text-xs text-amber-700"> |
| Your payment was cancelled. You can try again anytime. |
| </p> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Header */} |
| <div className="mb-8"> |
| <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> |
| <div> |
| <h1 className="text-2xl sm:text-3xl font-bold text-stone-900"> |
| Welcome back, {firstName} |
| </h1> |
| <p className="text-stone-600 mt-1">Track your membership and training progress</p> |
| </div> |
| <button |
| type="button" |
| onClick={() => setAiOpen(true)} |
| className="inline-flex items-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2 shadow-sm" |
| > |
| <MessageCircle className="w-4 h-4" /> |
| Ask the Coach |
| </button> |
| </div> |
| </div> |
| |
| {/* Plan Acceptance Form - Show when a plan is selected */} |
| {selectedPlan && ( |
| <PlanAcceptance |
| plan={selectedPlan} |
| email={studentEmail} |
| onComplete={() => { |
| setSelectedPlan(null); |
| loadData(); |
| }} |
| onCancel={() => setSelectedPlan(null)} |
| /> |
| )} |
| |
| {/* Choose Your Perfect Plan Section - Show default plans only when no active membership and no selected plan */} |
| {!activeMembership && !selectedPlan && defaultPlans.length > 0 && ( |
| <div className="mb-6 bg-gradient-to-br from-blue-50 to-white border-2 border-blue-200 rounded-2xl shadow-sm"> |
| <div className="px-5 py-4 border-b border-blue-100"> |
| <h2 className="text-2xl font-bold text-stone-900">Choose Your Perfect Plan</h2> |
| <p className="text-stone-600 mt-1">Start your karate journey today</p> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> |
| {defaultPlans.map((plan) => ( |
| <div |
| key={plan.id} |
| className="p-6 bg-white rounded-lg border-2 border-stone-200 hover:border-red-400 transition-all" |
| > |
| <div className="mb-4"> |
| <h3 className="text-xl font-bold text-stone-900">{plan.name}</h3> |
| <div className="mt-2 flex items-baseline gap-1"> |
| <span className="text-3xl font-bold text-red-600">${plan.price}</span> |
| <span className="text-stone-500">/ {plan.billing_period}</span> |
| </div> |
| </div> |
| <div className="space-y-2 mb-4"> |
| <div className="flex items-center gap-2 text-sm text-stone-600"> |
| <CheckCircle2 className="w-4 h-4 text-green-600" /> |
| Up to {plan.max_students} student{plan.max_students > 1 ? "s" : ""} |
| </div> |
| {plan.description && ( |
| <p className="text-sm text-stone-600">{plan.description}</p> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={() => handleSelectPlan(plan)} |
| className="w-full rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium py-2.5" |
| > |
| Select Plan |
| </button> |
| </div> |
| ))} |
| </div> |
| |
| {/* Help Section */} |
| <div className="p-6 bg-gradient-to-r from-purple-50 to-pink-50 rounded-lg border border-purple-200"> |
| <div className="flex flex-col md:flex-row items-center gap-4"> |
| <div className="flex-1 text-center md:text-left"> |
| <h4 className="font-semibold text-stone-900 mb-2"> |
| Need help choosing a plan that suits you? |
| </h4> |
| <p className="text-sm text-stone-600"> |
| Talk to us, and we provide a free consultation to start your journey |
| </p> |
| </div> |
| <button |
| type="button" |
| onClick={() => setAiOpen(true)} |
| className="inline-flex items-center gap-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium px-4 py-2 whitespace-nowrap" |
| > |
| <MessageCircle className="w-4 h-4" /> |
| Get Free Consultation |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Current Membership Status Card */} |
| {activeMembership && ( |
| <div className="bg-white border border-red-200 rounded-2xl shadow-sm mb-6 bg-gradient-to-br from-white to-red-50"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <div className="flex flex-col sm:flex-row justify-between items-start gap-4"> |
| <div> |
| <h2 className="text-lg font-semibold text-stone-900 flex items-center gap-2"> |
| Your Membership |
| <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ |
| activeMembership.status === "active" |
| ? "bg-green-50 text-green-700" |
| : activeMembership.status === "paused" |
| ? "bg-blue-50 text-blue-700" |
| : activeMembership.status === "cancelled" |
| ? "bg-amber-50 text-amber-700" |
| : "bg-stone-50 text-stone-700" |
| }`}> |
| {activeMembership.status === "cancelled" |
| ? "Cancelled (Active until renewal)" |
| : activeMembership.status === "paused" |
| ? "Paused" |
| : activeMembership.status} |
| </span> |
| </h2> |
| <p className="text-sm text-stone-500 mt-2"> |
| {activeMembership.plan_name} • {activeMembership.billing_period} |
| </p> |
| </div> |
| <div className="flex flex-col sm:flex-row gap-2 items-start sm:items-center"> |
| <div className="text-left sm:text-right"> |
| <div className="text-sm text-stone-500">Member since</div> |
| <div className="font-semibold text-stone-900"> |
| {activeMembership.start_date |
| ? format(new Date(activeMembership.start_date), "MMMM yyyy") |
| : "-"} |
| </div> |
| </div> |
| <button |
| type="button" |
| onClick={() => setShowManageMembership(!showManageMembership)} |
| className="inline-flex items-center justify-center rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 whitespace-nowrap" |
| > |
| {showManageMembership ? "Close" : "Manage"} |
| </button> |
| </div> |
| </div> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4"> |
| {/* Renewal Date / Resume Date */} |
| <div className="flex items-center gap-3 p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center"> |
| <Calendar className="w-5 h-5 text-red-600" /> |
| </div> |
| <div> |
| <div className="text-xs text-stone-500"> |
| {activeMembership.status === "paused" ? "Resume Date" : "Renewal Date"} |
| </div> |
| <div className="font-semibold text-stone-900"> |
| {activeMembership.status === "paused" && resumeDate |
| ? format(resumeDate, "MMM d, yyyy") |
| : activeMembership.renewal_date |
| ? format(new Date(activeMembership.renewal_date), "MMM d, yyyy") |
| : "-"} |
| </div> |
| </div> |
| </div> |
| |
| {/* Days Remaining */} |
| <div className="flex items-center gap-3 p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center"> |
| <Clock className="w-5 h-5 text-amber-600" /> |
| </div> |
| <div> |
| <div className="text-xs text-stone-500">Days Remaining</div> |
| <div className="font-semibold text-stone-900"> |
| {daysUntilRenewal !== null ? `${daysUntilRenewal} days` : "-"} |
| </div> |
| </div> |
| </div> |
| |
| {/* Amount */} |
| <div className="flex items-center gap-3 p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center"> |
| <CreditCard className="w-5 h-5 text-green-600" /> |
| </div> |
| <div> |
| <div className="text-xs text-stone-500">Amount</div> |
| <div className="font-semibold text-stone-900"> |
| ${Number(activeMembership.price).toFixed(2)} |
| </div> |
| </div> |
| </div> |
| |
| {/* Students */} |
| <div className="flex items-center gap-3 p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> |
| <Users className="w-5 h-5 text-blue-600" /> |
| </div> |
| <div> |
| <div className="text-xs text-stone-500">Students</div> |
| <div className="font-semibold text-stone-900"> |
| {myStudents.length} / {activeMembership.max_students || 1} |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Manage Membership Section */} |
| {showManageMembership && ( |
| <div className="border-t pt-4 mt-4 space-y-4"> |
| <h3 className="font-semibold text-stone-900">Manage Your Membership</h3> |
| |
| {/* Pause Membership - Only show if not already paused and no pending request */} |
| {!hasPausedBefore && !pendingPauseRequest && activeMembership?.status !== "paused" && ( |
| <div className="p-4 bg-blue-50 rounded-lg border border-blue-200"> |
| {!showPauseConfirm ? ( |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1"> |
| <h4 className="font-semibold text-stone-900 mb-1">Pause Membership</h4> |
| <p className="text-sm text-stone-600 mb-3"> |
| Need a break? Pause your membership for 1 month. Your renewal date will be pushed by 30 days. |
| </p> |
| <p className="text-xs text-stone-500"> |
| Current renewal: {activeMembership.renewal_date |
| ? format(new Date(activeMembership.renewal_date), "MMM d, yyyy") |
| : "-"} |
| </p> |
| </div> |
| <button |
| type="button" |
| onClick={() => { |
| setShowPauseConfirm(true); |
| setPauseMessage(null); |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-blue-600 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-100 whitespace-nowrap" |
| > |
| Pause for 1 Month |
| </button> |
| </div> |
| ) : ( |
| <div> |
| <h4 className="font-semibold text-blue-900 mb-2 flex items-center gap-2"> |
| <span className="text-yellow-600">⚠️</span> Confirm Pause |
| </h4> |
| <div className="bg-white p-4 rounded border border-blue-300 mb-4"> |
| <p className="text-sm text-stone-700 mb-2">Your membership will be paused for 1 month:</p> |
| <ul className="text-sm text-stone-600 space-y-1 list-disc list-inside"> |
| <li>During this period you will not be able to participate in classes and activities</li> |
| <li>You can only pause once during your current active membership</li> |
| <li>You can continue your classes after the pause period is over</li> |
| <li> |
| Your renewal date will be pushed to{" "} |
| {activeMembership.renewal_date |
| ? format( |
| new Date( |
| new Date(activeMembership.renewal_date).setDate( |
| new Date(activeMembership.renewal_date).getDate() + 30 |
| ) |
| ), |
| "MMM d, yyyy" |
| ) |
| : "-"} |
| </li> |
| </ul> |
| </div> |
| {pauseMessage && ( |
| <div className={`mb-4 p-3 rounded-lg text-sm ${ |
| pauseMessage.type === "success" |
| ? "bg-green-50 text-green-900 border border-green-200" |
| : "bg-red-50 text-red-900 border border-red-200" |
| }`}> |
| {pauseMessage.text} |
| </div> |
| )} |
| <div className="flex gap-2"> |
| <button |
| type="button" |
| onClick={async () => { |
| setIsPausing(true); |
| setPauseMessage(null); |
| try { |
| const response = await api.post(`/student/memberships/${activeMembership.id}/pause-request`); |
| setPauseMessage({ |
| type: "success", |
| text: "Pause request submitted! Waiting for admin approval." |
| }); |
| // Wait for user to read the message, then refresh data and close dialog |
| setTimeout(async () => { |
| await loadData(); |
| setTimeout(() => { |
| setShowPauseConfirm(false); |
| setShowManageMembership(false); |
| setPauseMessage(null); |
| }, 500); |
| }, 4000); |
| } catch (err) { |
| setPauseMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to pause membership. Please try again." |
| }); |
| } finally { |
| setIsPausing(false); |
| } |
| }} |
| disabled={isPausing} |
| className="inline-flex items-center justify-center rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 disabled:opacity-60" |
| > |
| {isPausing ? "Pausing..." : "Yes, Pause"} |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setShowPauseConfirm(false); |
| setPauseMessage(null); |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50" |
| > |
| No, Don't Pause |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Cancel Membership - Only show if not cancelled and no pending request */} |
| {activeMembership?.status !== "cancelled" && !pendingCancelRequest && ( |
| <div className="p-4 bg-red-50 rounded-lg border border-red-200"> |
| {!showCancelConfirm ? ( |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1"> |
| <h4 className="font-semibold text-stone-900 mb-1">Cancel Membership</h4> |
| <p className="text-sm text-stone-600"> |
| Cancel your subscription and stop future auto-payments. You can continue using classes until your current period ends. |
| </p> |
| </div> |
| <button |
| type="button" |
| onClick={() => { |
| setShowCancelConfirm(true); |
| setCancelMessage(null); |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-red-600 px-3 py-1.5 text-sm text-red-700 hover:bg-red-100 whitespace-nowrap" |
| > |
| Cancel Subscription |
| </button> |
| </div> |
| ) : ( |
| <div> |
| <h4 className="font-semibold text-red-900 mb-2 flex items-center gap-2"> |
| <span className="text-yellow-600">⚠️</span> Confirm Cancellation |
| </h4> |
| <div className="bg-white p-4 rounded border border-red-300 mb-4"> |
| <p className="text-sm text-stone-700 mb-2">Are you sure you want to cancel your subscription?</p> |
| <ul className="text-sm text-stone-600 space-y-1 list-disc list-inside"> |
| <li>Your next auto-payment will be stopped</li> |
| <li> |
| You can continue using classes until{" "} |
| {activeMembership.renewal_date |
| ? format(new Date(activeMembership.renewal_date), "MMM d, yyyy") |
| : "current period ends"} |
| </li> |
| <li>You can reactivate your membership anytime</li> |
| </ul> |
| </div> |
| {cancelMessage && ( |
| <div className={`mb-4 p-3 rounded-lg text-sm ${ |
| cancelMessage.type === "success" |
| ? "bg-green-50 text-green-900 border border-green-200" |
| : "bg-red-50 text-red-900 border border-red-200" |
| }`}> |
| {cancelMessage.text} |
| </div> |
| )} |
| <div className="flex gap-2"> |
| <button |
| type="button" |
| onClick={async () => { |
| setIsCancelling(true); |
| setCancelMessage(null); |
| try { |
| const response = await api.post(`/student/memberships/${activeMembership.id}/cancel-request`); |
| // Check if response is successful (status 200-299) |
| if (response.status >= 200 && response.status < 300) { |
| setCancelMessage({ |
| type: "success", |
| text: "Cancellation request submitted! Waiting for admin approval." |
| }); |
| // Wait for user to read the message, then refresh data and close dialog |
| setTimeout(async () => { |
| await loadData(); |
| setTimeout(() => { |
| setShowCancelConfirm(false); |
| setShowManageMembership(false); |
| setCancelMessage(null); |
| }, 500); |
| }, 4000); |
| } else { |
| setCancelMessage({ |
| type: "error", |
| text: response.data?.detail || "Failed to cancel membership. Please try again." |
| }); |
| } |
| } catch (err) { |
| // If error but status is 200, it might be a false error |
| if (err.response?.status >= 200 && err.response?.status < 300) { |
| setCancelMessage({ |
| type: "success", |
| text: `Subscription cancelled! Auto-payment stopped. You can use classes until ${format( |
| new Date(activeMembership.renewal_date), |
| "MMM d, yyyy" |
| )}.` |
| }); |
| // Wait for user to read the message, then refresh data and close dialog |
| setTimeout(async () => { |
| await loadData(); |
| setTimeout(() => { |
| setShowCancelConfirm(false); |
| setShowManageMembership(false); |
| setCancelMessage(null); |
| }, 500); |
| }, 4000); |
| } else { |
| setCancelMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to submit cancellation request. Please try again." |
| }); |
| } |
| } finally { |
| setIsCancelling(false); |
| } |
| }} |
| disabled={isCancelling} |
| className="inline-flex items-center justify-center rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2 disabled:opacity-60" |
| > |
| {isCancelling ? "Submitting..." : "Yes, Cancel Subscription"} |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setShowCancelConfirm(false); |
| setCancelMessage(null); |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50" |
| > |
| No, Keep My Membership |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Pending Requests */} |
| {(pendingPauseRequest || pendingCancelRequest) && ( |
| <div className="p-4 bg-amber-50 rounded-lg border border-amber-200"> |
| <h4 className="font-semibold text-stone-900 mb-2">Pending Requests</h4> |
| {pendingPauseRequest && ( |
| <div className="mb-2 p-3 bg-white rounded border border-amber-300"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <span className="text-sm font-medium text-stone-900">Pause Request</span> |
| <p className="text-xs text-stone-600 mt-1">Waiting for admin approval</p> |
| </div> |
| <button |
| type="button" |
| onClick={async () => { |
| setWithdrawPauseMessage(null); |
| try { |
| await api.post(`/student/memberships/${activeMembership.id}/requests/${pendingPauseRequest.id}/withdraw`); |
| setWithdrawPauseMessage({ |
| type: "success", |
| text: "Pause request withdrawn successfully." |
| }); |
| // Wait for user to read the message, then refresh data |
| setTimeout(async () => { |
| await loadData(); |
| setTimeout(() => { |
| setWithdrawPauseMessage(null); |
| }, 500); |
| }, 4000); |
| } catch (err) { |
| setWithdrawPauseMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to withdraw request. Please try again." |
| }); |
| } |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-amber-300 px-3 py-1 text-sm text-amber-700 hover:bg-amber-100" |
| > |
| Withdraw |
| </button> |
| </div> |
| {withdrawPauseMessage && ( |
| <div className={`mt-2 p-2 rounded-lg text-xs ${ |
| withdrawPauseMessage.type === "success" |
| ? "bg-green-50 text-green-800 border border-green-200" |
| : "bg-red-50 text-red-800 border border-red-200" |
| }`}> |
| {withdrawPauseMessage.text} |
| </div> |
| )} |
| </div> |
| )} |
| {pendingCancelRequest && ( |
| <div className="p-3 bg-white rounded border border-amber-300"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <span className="text-sm font-medium text-stone-900">Cancellation Request</span> |
| <p className="text-xs text-stone-600 mt-1">Waiting for admin approval</p> |
| </div> |
| <button |
| type="button" |
| onClick={async () => { |
| setWithdrawCancelMessage(null); |
| try { |
| await api.post(`/student/memberships/${activeMembership.id}/requests/${pendingCancelRequest.id}/withdraw`); |
| setWithdrawCancelMessage({ |
| type: "success", |
| text: "Cancellation request withdrawn successfully." |
| }); |
| // Wait for user to read the message, then refresh data |
| setTimeout(async () => { |
| await loadData(); |
| setTimeout(() => { |
| setWithdrawCancelMessage(null); |
| }, 500); |
| }, 4000); |
| } catch (err) { |
| setWithdrawCancelMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to withdraw request. Please try again." |
| }); |
| } |
| }} |
| className="inline-flex items-center justify-center rounded-lg border border-amber-300 px-3 py-1 text-sm text-amber-700 hover:bg-amber-100" |
| > |
| Withdraw |
| </button> |
| </div> |
| {withdrawCancelMessage && ( |
| <div className={`mt-2 p-2 rounded-lg text-xs ${ |
| withdrawCancelMessage.type === "success" |
| ? "bg-green-50 text-green-800 border border-green-200" |
| : "bg-red-50 text-red-800 border border-red-200" |
| }`}> |
| {withdrawCancelMessage.text} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Resume Membership (for paused) */} |
| {activeMembership?.status === "paused" && ( |
| <div className="p-4 bg-green-50 rounded-lg border border-green-200"> |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1"> |
| <h4 className="font-semibold text-stone-900 mb-1">Resume Membership</h4> |
| <p className="text-sm text-stone-600"> |
| Resume your membership immediately. Your renewal date will be auto adjusted based on the number of days paused. |
| </p> |
| {resumeMessage && ( |
| <div className={`mt-2 p-2 rounded-lg text-xs ${ |
| resumeMessage.type === "success" |
| ? "bg-green-50 text-green-800 border border-green-200" |
| : "bg-red-50 text-red-800 border border-red-200" |
| }`}> |
| {resumeMessage.text} |
| </div> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={async () => { |
| setResumeMessage(null); |
| try { |
| await api.post(`/student/memberships/${activeMembership.id}/resume`); |
| setResumeMessage({ |
| type: "success", |
| text: "Membership resumed successfully!" |
| }); |
| setTimeout(() => { |
| loadData(); |
| setShowManageMembership(false); |
| setResumeMessage(null); |
| }, 1500); |
| } catch (err) { |
| setResumeMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to resume membership. Please try again." |
| }); |
| } |
| }} |
| className="inline-flex items-center justify-center rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-medium px-4 py-2 whitespace-nowrap" |
| > |
| Resume |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {/* Reactivate Membership (for cancelled) */} |
| {canReactivate && ( |
| <div className="p-4 bg-green-50 rounded-lg border border-green-200"> |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1"> |
| <h4 className="font-semibold text-stone-900 mb-1">Reactivate Membership</h4> |
| <p className="text-sm text-stone-600"> |
| Reactivate your membership. Payment required. Membership will start from {activeMembership.renewal_date |
| ? format(new Date(activeMembership.renewal_date), "MMM d, yyyy") |
| : "-"}. |
| </p> |
| {reactivateMessage && ( |
| <div className={`mt-2 p-2 rounded-lg text-xs ${ |
| reactivateMessage.type === "error" |
| ? "bg-red-50 text-red-800 border border-red-200" |
| : "" |
| }`}> |
| {reactivateMessage.text} |
| </div> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={async () => { |
| setReactivateMessage(null); |
| try { |
| const response = await api.post(`/student/memberships/${activeMembership.id}/reactivate`); |
| if (response.data?.checkout_url) { |
| window.location.href = response.data.checkout_url; |
| } |
| } catch (err) { |
| setReactivateMessage({ |
| type: "error", |
| text: err.response?.data?.detail || "Failed to create reactivation payment. Please try again." |
| }); |
| } |
| }} |
| className="inline-flex items-center justify-center rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-medium px-4 py-2 whitespace-nowrap" |
| > |
| Reactivate |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| |
| {/* Pending Invitations - Show at top if there are any */} |
| {(pendingInvites.length > 0 || pendingExamInvites.length > 0) && ( |
| <div className="bg-white border border-amber-200 rounded-2xl shadow-sm mb-6 bg-gradient-to-br from-amber-50 to-white"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <h2 className="text-lg font-semibold text-stone-900 flex items-center gap-2"> |
| <Clock className="w-5 h-5 text-amber-600" /> |
| Pending Invitations |
| </h2> |
| <p className="text-sm text-stone-500 mt-1"> |
| You have pending invitations - pay to activate |
| </p> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="space-y-4"> |
| {/* Membership Invites */} |
| {pendingInvites.map((invite) => ( |
| <div key={invite.id} className="p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="flex flex-col sm:flex-row justify-between items-start gap-4"> |
| <div className="flex-1"> |
| <div className="font-semibold text-lg text-stone-900">{invite.plan_name}</div> |
| <div className="text-sm text-stone-500 mt-1"> |
| ${invite.plan_price} • Up to {invite.max_students} student(s) |
| </div> |
| {invite.class_details && ( |
| <div className="mt-3 p-3 bg-stone-50 rounded text-sm"> |
| <div className="font-medium text-stone-700 mb-1">Classes Included:</div> |
| <p className="text-stone-600">{invite.class_details}</p> |
| </div> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={() => { |
| setSelectedInvite(invite); |
| }} |
| className="inline-flex items-center gap-2 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 w-full sm:w-auto" |
| > |
| Complete Invitation |
| <ArrowRight className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| ))} |
| {/* Exam Invites */} |
| {pendingExamInvites.map((reg) => { |
| const exam = reg.exam || {}; |
| const batch = reg.batch || {}; |
| return ( |
| <div key={reg.id} className="p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="flex flex-col sm:flex-row justify-between items-start gap-4"> |
| <div className="flex-1"> |
| <div className="font-semibold text-lg text-stone-900">{exam.name || "Exam"}</div> |
| <div className="text-sm text-stone-500 mt-1"> |
| ${exam.exam_fee || 0} • {batch.batch_name || "Batch"} |
| </div> |
| {exam.exam_date && ( |
| <div className="text-sm text-stone-600 mt-1"> |
| Exam Date: {format(new Date(exam.exam_date), "MMM d, yyyy")} |
| </div> |
| )} |
| </div> |
| <button |
| type="button" |
| onClick={() => navigate("/student/exams")} |
| className="inline-flex items-center gap-2 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 w-full sm:w-auto" |
| > |
| View Exam Details |
| <ArrowRight className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Invite Acceptance Inline */} |
| {selectedInvite && ( |
| <div id="invite-acceptance-container" className="mb-6 bg-white border border-stone-200 rounded-2xl shadow-sm"> |
| <div className="px-4 sm:px-6 py-4 border-b border-stone-100 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> |
| <div> |
| <h2 className="text-base font-semibold text-stone-900">Complete Your Membership</h2> |
| <p className="text-sm text-stone-500"> |
| Add student information and complete payment for {selectedInvite.plan_name} |
| </p> |
| </div> |
| <button |
| type="button" |
| onClick={() => setSelectedInvite(null)} |
| className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-2.5 py-1.5 text-sm text-stone-600 hover:bg-stone-50" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="px-4 sm:px-6 py-4"> |
| <InviteAcceptance |
| key={selectedInvite.id} |
| invite={selectedInvite} |
| onComplete={() => { |
| setSelectedInvite(null); |
| window.location.reload(); |
| }} |
| onCancel={() => { |
| setSelectedInvite(null); |
| }} |
| /> |
| </div> |
| </div> |
| )} |
| |
| |
| {/* Belt Progression Path - Only show with active membership */} |
| {activeMembership && ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm mb-6"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <h2 className="text-lg font-semibold text-stone-900 flex items-center gap-2"> |
| 🥋 Belt Progression Path |
| </h2> |
| <p className="text-sm text-stone-500 mt-1">Track your students' journey to black belt</p> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="space-y-6"> |
| {examProgress.map((progress, idx) => ( |
| <div key={idx} className="p-4 bg-stone-50 rounded-lg"> |
| <div className="flex justify-between items-start mb-3"> |
| <div> |
| <div className="font-semibold text-stone-900">{progress.student}</div> |
| <div className="text-sm text-stone-500">Current: {progress.levelName}</div> |
| </div> |
| <span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800"> |
| Level {progress.currentLevel} |
| </span> |
| </div> |
| |
| {/* Progress Bar */} |
| <div className="mb-3"> |
| <div className="flex justify-between text-xs text-stone-500 mb-1"> |
| <span>{progress.levelName}</span> |
| <span>{progress.nextLevel}</span> |
| </div> |
| <div className="h-3 bg-stone-200 rounded-full overflow-hidden"> |
| <div |
| className="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all" |
| style={{ width: `${progress.progress}%` }} |
| /> |
| </div> |
| <div className="text-xs text-stone-500 mt-1 text-right"> |
| {progress.progress}% to next belt |
| </div> |
| </div> |
| |
| {/* Belt Path Visualization */} |
| <div className="flex items-center gap-2 overflow-x-auto py-2"> |
| {["White", "Yellow", "Orange", "Green", "Blue", "Brown", "Black"].map( |
| (belt, beltIdx) => { |
| const isCompleted = beltIdx < progress.currentLevel; |
| const isCurrent = beltIdx === progress.currentLevel - 1; |
| const beltColors = { |
| White: "bg-gray-100 border-gray-300", |
| Yellow: "bg-yellow-100 border-yellow-400", |
| Orange: "bg-orange-100 border-orange-400", |
| Green: "bg-green-100 border-green-400", |
| Blue: "bg-blue-100 border-blue-400", |
| Brown: "bg-amber-800 border-amber-900 text-white", |
| Black: "bg-stone-900 border-stone-950 text-white", |
| }; |
| return ( |
| <div key={belt} className="flex flex-col items-center"> |
| <div |
| className={`w-10 h-10 rounded-full border-2 flex items-center justify-center text-xs font-medium ${ |
| beltColors[belt] |
| } ${ |
| isCurrent ? "ring-2 ring-red-500 ring-offset-2" : "" |
| } ${isCompleted ? "opacity-100" : "opacity-40"}`} |
| > |
| {isCompleted ? "✓" : beltIdx + 1} |
| </div> |
| <span className="text-xs mt-1 text-stone-500">{belt}</span> |
| </div> |
| ); |
| } |
| )} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* My Students */} |
| {activeMembership && ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm mb-6"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <div className="flex justify-between items-center"> |
| <div> |
| <h2 className="text-lg font-semibold text-stone-900">My Students</h2> |
| <p className="text-sm text-stone-500 mt-1"> |
| {canAddMoreStudents |
| ? `You can add ${(activeMembership.max_students || 1) - myStudents.length} more student(s)` |
| : "Maximum students reached"} |
| </p> |
| </div> |
| {canAddMoreStudents && ( |
| <button |
| type="button" |
| onClick={() => setShowAddStudent(!showAddStudent)} |
| className="inline-flex items-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2" |
| > |
| <Plus className="w-4 h-4" /> |
| Add Student |
| </button> |
| )} |
| </div> |
| </div> |
| <div className="px-5 py-4"> |
| {showAddStudent && ( |
| <form onSubmit={handleAddStudent} className="mb-6 p-4 bg-stone-50 rounded-lg space-y-4"> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div> |
| <label className="block text-sm font-medium text-stone-700 mb-1"> |
| First Name * |
| </label> |
| <input |
| type="text" |
| value={studentForm.first_name} |
| onChange={(e) => |
| setStudentForm({ ...studentForm, first_name: e.target.value }) |
| } |
| required |
| className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500" |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-stone-700 mb-1"> |
| Last Name * |
| </label> |
| <input |
| type="text" |
| value={studentForm.last_name} |
| onChange={(e) => |
| setStudentForm({ ...studentForm, last_name: e.target.value }) |
| } |
| required |
| className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500" |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-stone-700 mb-1"> |
| Date of Birth |
| </label> |
| <input |
| type="date" |
| value={studentForm.date_of_birth} |
| onChange={(e) => |
| setStudentForm({ ...studentForm, date_of_birth: e.target.value }) |
| } |
| className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500" |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-stone-700 mb-1">Gender</label> |
| <select |
| value={studentForm.gender} |
| onChange={(e) => |
| setStudentForm({ ...studentForm, gender: e.target.value }) |
| } |
| className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500" |
| > |
| <option value="">Select gender</option> |
| <option value="Male">Male</option> |
| <option value="Female">Female</option> |
| <option value="Other">Other</option> |
| <option value="Prefer not to say">Prefer not to say</option> |
| </select> |
| </div> |
| </div> |
| <div> |
| <label className="block text-sm font-medium text-stone-700 mb-1"> |
| Medical Notes |
| </label> |
| <textarea |
| value={studentForm.medical_notes} |
| onChange={(e) => |
| setStudentForm({ ...studentForm, medical_notes: e.target.value }) |
| } |
| placeholder="Any allergies, conditions, or special needs..." |
| rows={2} |
| className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500" |
| /> |
| </div> |
| {studentMessage && ( |
| <div className={`p-3 rounded-lg text-sm ${ |
| studentMessage.type === "success" |
| ? "bg-green-50 text-green-800 border border-green-200" |
| : "bg-red-50 text-red-800 border border-red-200" |
| }`}> |
| {studentMessage.text} |
| </div> |
| )} |
| <div className="flex gap-2"> |
| <button |
| type="submit" |
| className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium" |
| > |
| Save Student |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setShowAddStudent(false); |
| setStudentForm({ |
| first_name: "", |
| last_name: "", |
| date_of_birth: "", |
| gender: "", |
| medical_notes: "", |
| }); |
| setStudentMessage(null); |
| }} |
| className="px-4 py-2 border border-stone-200 rounded-lg hover:bg-stone-50 text-sm font-medium" |
| > |
| Cancel |
| </button> |
| </div> |
| </form> |
| )} |
| |
| <div className="space-y-3"> |
| {myStudents.length === 0 ? ( |
| <div className="text-center py-8 text-stone-500"> |
| <Users className="w-12 h-12 mx-auto mb-3 opacity-30" /> |
| <p>No students added yet</p> |
| </div> |
| ) : ( |
| myStudents.map((student) => { |
| const studentEnrollments = enrollments.filter( |
| (e) => e.student_id === student.id |
| ); |
| const age = calculateAge(student.date_of_birth); |
| return ( |
| <div key={student.id} className="p-4 bg-stone-50 rounded-lg"> |
| <div className="flex justify-between items-start"> |
| <div> |
| <div className="font-semibold text-stone-900"> |
| {student.first_name} {student.last_name} |
| </div> |
| <div className="text-sm text-stone-500"> |
| {age && `Age: ${age}`} |
| {student.gender && ` • Gender: ${student.gender}`} |
| </div> |
| </div> |
| <span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700"> |
| Active |
| </span> |
| </div> |
| {studentEnrollments.length > 0 && ( |
| <div className="mt-2 pt-2 border-t text-sm text-stone-600"> |
| Classes: {studentEnrollments.map((e) => e.class_name).join(", ")} |
| </div> |
| )} |
| </div> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Active Classes Summary */} |
| {enrollments.length > 0 && ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm mb-6"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <h2 className="text-lg font-semibold text-stone-900 flex items-center gap-2"> |
| <CheckCircle2 className="w-5 h-5 text-green-600" /> |
| Active Classes |
| </h2> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> |
| {Object.values(enrollmentsByClass).map((classData) => ( |
| <div key={classData.class_id} className="p-4 bg-stone-50 rounded-lg"> |
| <div className="font-semibold text-stone-900">{classData.class_name}</div> |
| <div className="text-sm text-stone-500 mt-1"> |
| Students: {classData.students.map((s) => s.student_name).join(", ")} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* What's Happening (replaces Available Membership Plans) */} |
| <div className="mb-8"> |
| <div className="flex items-center gap-2 mb-4"> |
| <TrendingUp className="w-5 h-5 text-stone-600" /> |
| <h2 className="text-xl font-bold text-stone-900">What's Happening</h2> |
| </div> |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| <div className="bg-white border border-stone-200 rounded-xl p-4"> |
| <h3 className="font-semibold text-stone-900 mb-2">New Advanced Kata Program</h3> |
| <p className="text-sm text-stone-600 mb-4"> |
| Master advanced kata forms with our new intensive program. Perfect for students |
| preparing for black belt exams. |
| </p> |
| <button |
| type="button" |
| className="inline-flex items-center gap-1 text-sm font-medium text-red-600 hover:text-red-700" |
| > |
| Know More <ArrowRight className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="bg-white border border-stone-200 rounded-xl p-4"> |
| <h3 className="font-semibold text-stone-900 mb-2">Competition Training Camp</h3> |
| <p className="text-sm text-stone-600 mb-4"> |
| Join our specialized competition training camp. Learn tournament strategies and |
| improve your competitive performance. |
| </p> |
| <button |
| type="button" |
| className="inline-flex items-center gap-1 text-sm font-medium text-red-600 hover:text-red-700" |
| > |
| Know More <ArrowRight className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="bg-white border border-stone-200 rounded-xl p-4"> |
| <h3 className="font-semibold text-stone-900 mb-2">Self-Defense Workshop</h3> |
| <p className="text-sm text-stone-600 mb-4"> |
| Practical self-defense techniques for all ages. Learn real-world applications of |
| karate in a safe, controlled environment. |
| </p> |
| <button |
| type="button" |
| className="inline-flex items-center gap-1 text-sm font-medium text-red-600 hover:text-red-700" |
| > |
| Know More <ArrowRight className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* AI Insights */} |
| <div className="bg-white border border-purple-200 rounded-2xl shadow-sm mb-6 bg-gradient-to-br from-purple-50 to-white"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <h2 className="text-lg font-semibold text-stone-900 flex items-center gap-2"> |
| <span className="text-2xl">🤖</span> |
| AI Training Insights |
| </h2> |
| </div> |
| <div className="px-5 py-4"> |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| <div className="p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="text-2xl mb-2">🔥</div> |
| <div className="font-semibold text-stone-900">Great Progress!</div> |
| <p className="text-sm text-stone-600 mt-1"> |
| {myStudents.length > 0 |
| ? `${myStudents[0].first_name} has attended 12 classes this month - 20% more than last month!` |
| : "Alex has attended 12 classes this month - 20% more than last month!"} |
| </p> |
| </div> |
| <div className="p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="text-2xl mb-2">🎯</div> |
| <div className="font-semibold text-stone-900">Focus Area</div> |
| <p className="text-sm text-stone-600 mt-1"> |
| {myStudents.length > 1 |
| ? `${myStudents[1].first_name} should practice kata forms before the next grading exam.` |
| : "Emma should practice kata forms before the next grading exam."} |
| </p> |
| </div> |
| <div className="p-4 bg-white rounded-lg border border-stone-200"> |
| <div className="text-2xl mb-2">⭐</div> |
| <div className="font-semibold text-stone-900">Achievement</div> |
| <p className="text-sm text-stone-600 mt-1"> |
| {myStudents.length > 0 |
| ? `${myStudents[0].first_name} is ready for Orange Belt exam! Schedule it soon.` |
| : "Alex is ready for Orange Belt exam! Schedule it soon."} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Membership History */} |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm"> |
| <div className="px-5 py-4 border-b border-stone-100"> |
| <h2 className="text-lg font-semibold text-stone-900">Membership History</h2> |
| <p className="text-sm text-stone-500 mt-1">Payment activities and membership records</p> |
| </div> |
| <div className="px-5 py-4"> |
| {loading ? ( |
| <p className="text-sm text-stone-500">Loading membership history…</p> |
| ) : memberships.length === 0 ? ( |
| <p className="text-sm text-stone-500"> |
| You don't have any membership records yet. |
| </p> |
| ) : ( |
| <div className="overflow-x-auto"> |
| <table className="min-w-full text-sm"> |
| <thead> |
| <tr className="text-left text-xs text-stone-500 uppercase border-b border-stone-100"> |
| <th className="py-2 pr-4">Plan</th> |
| <th className="py-2 pr-4">Status</th> |
| <th className="py-2 pr-4">{(memberships.some(m => m.is_cancellation_entry) || memberships.some(m => m.is_pause_entry)) ? "Date" : "Started"}</th> |
| <th className="py-2 pr-4">{memberships.some(m => m.is_pause_entry) ? "Resume/Renewal" : "Renewal"}</th> |
| <th className="py-2 pr-4">Price</th> |
| </tr> |
| </thead> |
| <tbody> |
| {memberships |
| .sort((a, b) => { |
| // Sort by date descending (most recent first) |
| const dateA = new Date(a.is_cancellation_entry || a.is_pause_entry ? a.start_date : a.start_date); |
| const dateB = new Date(b.is_cancellation_entry || b.is_pause_entry ? b.start_date : b.start_date); |
| return dateB - dateA; |
| }) |
| .map((m) => ( |
| <tr key={m.id} className="border-b border-stone-50"> |
| <td className="py-2 pr-4 text-stone-900"> |
| {m.is_cancellation_entry ? ( |
| <span className="text-stone-600 italic">Cancelled</span> |
| ) : m.is_pause_entry ? ( |
| <span className="text-stone-600 italic">Paused</span> |
| ) : ( |
| m.plan_name |
| )} |
| </td> |
| <td className="py-2 pr-4"> |
| <span |
| className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ |
| m.status === "active" |
| ? "bg-green-50 text-green-700" |
| : m.status === "paused" |
| ? "bg-blue-50 text-blue-700" |
| : m.status === "expired" |
| ? "bg-red-50 text-red-700" |
| : m.status === "cancelled" |
| ? "bg-amber-50 text-amber-700" |
| : "bg-stone-50 text-stone-700" |
| }`} |
| > |
| {m.status} |
| </span> |
| </td> |
| <td className="py-2 pr-4 text-stone-900"> |
| {m.is_cancellation_entry ? ( |
| <span> |
| Cancelled: {m.start_date |
| ? format(new Date(m.start_date), "MMM d, yyyy") |
| : "-"} |
| </span> |
| ) : m.is_pause_entry ? ( |
| <span> |
| Paused: {m.start_date |
| ? format(new Date(m.start_date), "MMM d, yyyy") |
| : "-"} |
| </span> |
| ) : ( |
| m.start_date |
| ? format(new Date(m.start_date), "MMM d, yyyy") |
| : "-" |
| )} |
| </td> |
| <td className="py-2 pr-4 text-stone-900"> |
| {m.renewal_date |
| ? format(new Date(m.renewal_date), "MMM d, yyyy") |
| : "-"} |
| </td> |
| <td className="py-2 pr-4 text-stone-900"> |
| ${Number(m.price).toFixed(2)} / {m.billing_period} |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* No membership and no invite message */} |
| {!activeMembership && pendingInvites.length === 0 && !loading && ( |
| <div className="bg-white border border-stone-200 rounded-2xl shadow-sm mt-6"> |
| <div className="px-5 py-12 text-center"> |
| <Users className="w-12 h-12 mx-auto mb-3 text-stone-300" /> |
| <p className="text-stone-500"> |
| You don't have an active membership or pending invitation. |
| </p> |
| <p className="text-sm text-stone-400 mt-2"> |
| Contact the dojo admin to get an invitation. |
| </p> |
| </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 object-cover" |
| /> |
| <div> |
| <div className="text-sm font-semibold text-stone-900">Arun Martial Arts</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); |
| 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 ${ |
| 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> |
| </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> |
| </> |
| )} |
|
|
| <StudentAIAssistant open={aiOpen} onClose={() => setAiOpen(false)} /> |
| </div> |
| ); |
| } |
|
|