feat(classroom): implement enrollment system (Phase 4)

- Add EnrollChildFlow component for parents to enroll children
- Add enrollment hooks (useEnrolledStudents, useCreateEnrollmentRequest, etc.)
- Update StudentManagerTab with enrolled students and pending requests
- Add "Enroll in another classroom" option for teachers who are parents
- Fix critical auth bug: convert guestId to user.id in all classroom API routes

The API routes were incorrectly using viewerId (guestId from cookie) when
they should have been using users.id. This caused 403 errors for all
classroom operations. Fixed by adding getOrCreateUser() to convert
guestId to proper user.id before calling business logic functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-22 13:31:04 -06:00
parent 398603c75a
commit 1952a412ed
15 changed files with 1465 additions and 123 deletions

View File

@@ -254,7 +254,13 @@
"Bash(mcp__sqlite__describe_table:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(npx tsx:*)"
"Bash(npx tsx:*)",
"Bash(xargs ls:*)",
"Bash(mcp__sqlite__list_tables)",
"WebFetch(domain:developer.chrome.com)",
"Bash(claude mcp add:*)",
"Bash(claude mcp:*)",
"Bash(git rev-parse:*)"
],
"deny": [],
"ask": []

View File

@@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { approveEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
@@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const result = await approveEnrollmentRequest(requestId, viewerId, 'teacher')
const result = await approveEnrollmentRequest(requestId, user.id, 'teacher')
return NextResponse.json({
request: result.request,

View File

@@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { denyEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; requestId: string }>
}
@@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, requestId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const request = await denyEnrollmentRequest(requestId, viewerId, 'teacher')
const request = await denyEnrollmentRequest(requestId, user.id, 'teacher')
return NextResponse.json({ request })
} catch (error) {

View File

@@ -1,4 +1,6 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
createEnrollmentRequest,
getPendingRequestsForClassroom,
@@ -7,6 +9,22 @@ import {
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
@@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
@@ -48,6 +67,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.playerId) {
@@ -55,9 +75,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
}
// Determine role: is user the teacher or a parent?
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, body.playerId)
const parentCheck = await isParent(user.id, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
@@ -71,7 +91,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
const request = await createEnrollmentRequest({
classroomId,
playerId: body.playerId,
requestedBy: viewerId,
requestedBy: user.id,
requestedByRole,
})

View File

@@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { unenrollStudent, getTeacherClassroom, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
@@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, playerId)
const parentCheck = await isParent(user.id, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(

View File

@@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
@@ -16,9 +34,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}

View File

@@ -1,7 +1,25 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { leaveSpecificClassroom, getTeacherClassroom, isParent } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string; playerId: string }>
}
@@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId, playerId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, playerId)
const parentCheck = await isParent(user.id, playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(

View File

@@ -1,4 +1,6 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
enterClassroom,
getClassroomPresence,
@@ -7,6 +9,22 @@ import {
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
@@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Verify user is the teacher of this classroom
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
if (!classroom || classroom.id !== classroomId) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
@@ -55,6 +74,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
if (!body.playerId) {
@@ -62,9 +82,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
}
// Check authorization: must be teacher of classroom OR parent of student
const classroom = await getTeacherClassroom(viewerId)
const classroom = await getTeacherClassroom(user.id)
const isTeacher = classroom?.id === classroomId
const parentCheck = await isParent(viewerId, body.playerId)
const parentCheck = await isParent(user.id, body.playerId)
if (!isTeacher && !parentCheck) {
return NextResponse.json(
@@ -76,7 +96,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
const result = await enterClassroom({
playerId: body.playerId,
classroomId,
enteredBy: viewerId,
enteredBy: user.id,
})
if (!result.success) {

View File

@@ -1,4 +1,6 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import {
deleteClassroom,
getClassroom,
@@ -7,6 +9,22 @@ import {
} from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
/**
* Get or create user record for a viewerId (guestId)
*/
async function getOrCreateUser(viewerId: string) {
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
user = newUser
}
return user
}
interface RouteParams {
params: Promise<{ classroomId: string }>
}
@@ -45,11 +63,12 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const body = await req.json()
// Handle code regeneration separately
if (body.regenerateCode) {
const newCode = await regenerateClassroomCode(classroomId, viewerId)
const newCode = await regenerateClassroomCode(classroomId, user.id)
if (!newCode) {
return NextResponse.json(
{ error: 'Not authorized or classroom not found' },
@@ -69,7 +88,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
}
const classroom = await updateClassroom(classroomId, viewerId, updates)
const classroom = await updateClassroom(classroomId, user.id, updates)
if (!classroom) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
@@ -92,8 +111,9 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
try {
const { classroomId } = await params
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
const success = await deleteClassroom(classroomId, viewerId)
const success = await deleteClassroom(classroomId, user.id)
if (!success) {
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })

View File

@@ -3,7 +3,7 @@
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
import { ClassroomDashboard, CreateClassroomForm } from '@/components/classroom'
import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
@@ -34,6 +34,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
// Classroom state - check if user is a teacher
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
const [showCreateClassroom, setShowCreateClassroom] = useState(false)
const [showEnrollChild, setShowEnrollChild] = useState(false)
// Filter state
const [searchQuery, setSearchQuery] = useState('')
@@ -190,6 +191,15 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
setShowCreateClassroom(false)
}, [])
// Handle enrollment flow
const handleEnrollChild = useCallback(() => {
setShowEnrollChild(true)
}, [])
const handleCloseEnrollChild = useCallback(() => {
setShowEnrollChild(false)
}, [])
// If user is a teacher, show the classroom dashboard
if (classroom) {
return (
@@ -235,6 +245,31 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
)
}
// Show enroll child flow if requested
if (showEnrollChild) {
return (
<PageWithNav>
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
})}
>
<EnrollChildFlow
children={players}
onSuccess={handleCloseEnrollChild}
onCancel={handleCloseEnrollChild}
/>
</main>
</PageWithNav>
)
}
// Parent view - show student list with filter bar
return (
<PageWithNav>
@@ -295,31 +330,67 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
Build your soroban skills one step at a time
</p>
{/* Become a Teacher option */}
{/* Parent/Teacher options */}
{!isLoadingClassroom && !classroom && (
<button
type="button"
onClick={handleBecomeTeacher}
data-action="become-teacher"
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gap: '12px',
marginTop: '16px',
padding: '8px 16px',
backgroundColor: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
🏫 Are you a teacher? Create a classroom
</button>
{/* Enroll in Classroom option - for parents with a teacher */}
{players.length > 0 && (
<button
type="button"
onClick={handleEnrollChild}
data-action="enroll-child"
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'green.900/30' : 'green.50',
color: isDark ? 'green.400' : 'green.700',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'green.900/50' : 'green.100',
borderColor: isDark ? 'green.500' : 'green.400',
},
})}
>
📚 Have a classroom code? Enroll your child
</button>
)}
{/* Become a Teacher option */}
<button
type="button"
onClick={handleBecomeTeacher}
data-action="become-teacher"
className={css({
padding: '8px 16px',
backgroundColor: 'transparent',
color: isDark ? 'blue.400' : 'blue.600',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.300',
borderRadius: '8px',
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
🏫 Are you a teacher? Create a classroom
</button>
</div>
)}
</header>

View File

@@ -6,6 +6,7 @@ import { useTheme } from '@/contexts/ThemeContext'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
import { ClassroomTab } from './ClassroomTab'
import { EnrollChildFlow } from './EnrollChildFlow'
import { StudentManagerTab } from './StudentManagerTab'
type TabId = 'classroom' | 'students'
@@ -29,6 +30,7 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [activeTab, setActiveTab] = useState<TabId>('classroom')
const [showEnrollChild, setShowEnrollChild] = useState(false)
const tabs: { id: TabId; label: string; icon: string }[] = [
{ id: 'classroom', label: 'Classroom', icon: '🏫' },
@@ -95,16 +97,45 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
borderColor: isDark ? 'green.800' : 'green.200',
})}
>
<h2
<div
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.700',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
})}
>
Your Children
</h2>
<h2
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.700',
})}
>
Your Children
</h2>
<button
type="button"
onClick={() => setShowEnrollChild(true)}
data-action="enroll-child-in-other-classroom"
className={css({
padding: '6px 12px',
backgroundColor: 'transparent',
color: isDark ? 'green.400' : 'green.700',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.400',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'green.900/50' : 'green.100',
},
})}
>
📚 Enroll in another classroom
</button>
</div>
<div
className={css({
display: 'flex',
@@ -155,6 +186,34 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
</section>
)}
{/* Enroll child modal */}
{showEnrollChild && (
<div
data-component="enroll-child-modal"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
zIndex: 1000,
})}
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEnrollChild(false)
}
}}
>
<EnrollChildFlow
children={ownChildren}
onSuccess={() => setShowEnrollChild(false)}
onCancel={() => setShowEnrollChild(false)}
/>
</div>
)}
{/* Tab navigation */}
<nav
className={css({

View File

@@ -0,0 +1,441 @@
'use client'
import { useCallback, useState } from 'react'
import type { Player } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import { useClassroomByCode, useCreateEnrollmentRequest } from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
interface EnrollChildFlowProps {
/** Children available to enroll (linked to current user) */
children: Player[]
/** Called when enrollment request is successfully created */
onSuccess?: () => void
/** Called when user cancels */
onCancel?: () => void
}
/**
* EnrollChildFlow - Parent enrollment flow
*
* Allows parents to:
* 1. Enter a classroom code
* 2. See classroom/teacher info
* 3. Select which child to enroll
* 4. Submit enrollment request
*/
export function EnrollChildFlow({ children, onSuccess, onCancel }: EnrollChildFlowProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Step state
const [code, setCode] = useState('')
const [selectedChildId, setSelectedChildId] = useState<string | null>(null)
// Look up classroom by code
const { data: classroom, isLoading: lookingUp, error: lookupError } = useClassroomByCode(code)
// Enrollment mutation
const createRequest = useCreateEnrollmentRequest()
const handleSubmit = useCallback(async () => {
if (!classroom || !selectedChildId) return
try {
await createRequest.mutateAsync({
classroomId: classroom.id,
playerId: selectedChildId,
})
onSuccess?.()
} catch {
// Error handled by mutation state
}
}, [classroom, selectedChildId, createRequest, onSuccess])
const selectedChild = children.find((c) => c.id === selectedChildId)
return (
<div
data-component="enroll-child-flow"
className={css({
padding: '24px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
maxWidth: '450px',
})}
>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Enroll in a Classroom
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
Enter the classroom code from your teacher to enroll your child.
</p>
{/* Step 1: Enter code */}
<div className={css({ marginBottom: '20px' })}>
<label
htmlFor="classroom-code"
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '6px',
})}
>
Classroom Code
</label>
<input
id="classroom-code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="e.g., ABC123"
maxLength={6}
data-input="classroom-code"
className={css({
width: '100%',
padding: '12px 14px',
backgroundColor: isDark ? 'gray.700' : 'gray.50',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
borderRadius: '8px',
fontSize: '1.25rem',
fontWeight: 'bold',
fontFamily: 'monospace',
letterSpacing: '0.15em',
textAlign: 'center',
textTransform: 'uppercase',
color: isDark ? 'white' : 'gray.800',
outline: 'none',
transition: 'border-color 0.15s ease',
_focus: {
borderColor: isDark ? 'blue.500' : 'blue.400',
},
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
fontWeight: 'normal',
letterSpacing: 'normal',
},
})}
/>
{/* Lookup status */}
{code.length >= 4 && (
<div className={css({ marginTop: '12px' })}>
{lookingUp && (
<p className={css({ fontSize: '0.875rem', color: isDark ? 'gray.400' : 'gray.500' })}>
Looking up classroom...
</p>
)}
{!lookingUp && !classroom && code.length >= 6 && (
<p className={css({ fontSize: '0.875rem', color: 'red.500' })}>
No classroom found with this code
</p>
)}
</div>
)}
</div>
{/* Step 2: Classroom found - show info */}
{classroom && (
<div
data-section="classroom-found"
className={css({
padding: '16px',
backgroundColor: isDark ? 'green.900/20' : 'green.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.200',
marginBottom: '20px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: '1.25rem' })}>🏫</span>
<div>
<p
className={css({
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.700',
})}
>
{classroom.name}
</p>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'green.400' : 'green.600',
})}
>
Teacher: {(classroom as { teacher?: { name?: string } }).teacher?.name ?? 'Teacher'}
</p>
</div>
</div>
</div>
)}
{/* Step 3: Select child */}
{classroom && children.length > 0 && (
<div className={css({ marginBottom: '20px' })}>
<label
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '10px',
})}
>
Select child to enroll
</label>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => setSelectedChildId(child.id)}
data-selected={selectedChildId === child.id}
data-action={`select-child-${child.id}`}
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
backgroundColor:
selectedChildId === child.id
? isDark
? 'blue.900/30'
: 'blue.50'
: isDark
? 'gray.700'
: 'gray.50',
border: '2px solid',
borderColor:
selectedChildId === child.id
? isDark
? 'blue.500'
: 'blue.400'
: isDark
? 'gray.600'
: 'gray.200',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.15s ease',
textAlign: 'left',
_hover: {
borderColor: isDark ? 'blue.500' : 'blue.400',
},
})}
>
<span
className={css({
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.125rem',
})}
style={{ backgroundColor: child.color }}
>
{child.emoji}
</span>
<span
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{child.name}
</span>
{selectedChildId === child.id && (
<span
className={css({
marginLeft: 'auto',
color: isDark ? 'blue.400' : 'blue.600',
fontSize: '1.25rem',
})}
>
</span>
)}
</button>
))}
</div>
</div>
)}
{/* Error display */}
{createRequest.error && (
<p
className={css({
fontSize: '0.8125rem',
color: 'red.500',
marginBottom: '12px',
})}
>
{createRequest.error.message}
</p>
)}
{/* Success display */}
{createRequest.isSuccess && (
<div
className={css({
padding: '16px',
backgroundColor: isDark ? 'green.900/20' : 'green.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.200',
marginBottom: '20px',
textAlign: 'center',
})}
>
<p
className={css({
fontWeight: 'bold',
color: isDark ? 'green.300' : 'green.700',
marginBottom: '4px',
})}
>
Enrollment Request Sent!
</p>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'green.400' : 'green.600',
})}
>
The teacher will review and approve the enrollment.
</p>
</div>
)}
{/* Actions */}
<div className={css({ display: 'flex', gap: '12px' })}>
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={createRequest.isPending}
data-action="cancel-enroll"
className={css({
flex: 1,
padding: '12px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
Cancel
</button>
)}
{!createRequest.isSuccess && (
<button
type="button"
onClick={handleSubmit}
disabled={!classroom || !selectedChildId || createRequest.isPending}
data-action="submit-enrollment"
className={css({
flex: 2,
padding: '12px',
backgroundColor:
classroom && selectedChildId
? isDark
? 'green.700'
: 'green.500'
: isDark
? 'gray.700'
: 'gray.300',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: classroom && selectedChildId ? 'pointer' : 'not-allowed',
transition: 'all 0.15s ease',
_hover: {
backgroundColor:
classroom && selectedChildId ? (isDark ? 'green.600' : 'green.600') : undefined,
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{createRequest.isPending ? 'Enrolling...' : `Enroll ${selectedChild?.name || 'Child'}`}
</button>
)}
{createRequest.isSuccess && onCancel && (
<button
type="button"
onClick={onCancel}
data-action="done"
className={css({
flex: 1,
padding: '12px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'green.600' : 'green.600',
},
})}
>
Done
</button>
)}
</div>
{/* No children message */}
{classroom && children.length === 0 && (
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
textAlign: 'center',
marginTop: '16px',
})}
>
You need to add a child first before enrolling in a classroom.
</p>
)}
</div>
)
}

View File

@@ -1,7 +1,17 @@
'use client'
import type { Classroom } from '@/db/schema'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import type { Classroom, Player } from '@/db/schema'
import { useTheme } from '@/contexts/ThemeContext'
import {
useEnrolledStudents,
usePendingEnrollmentRequests,
useApproveEnrollmentRequest,
useDenyEnrollmentRequest,
useUnenrollStudent,
type EnrollmentRequestWithRelations,
} from '@/hooks/useClassroom'
import { css } from '../../../styled-system/css'
import { ClassroomCodeShare } from './ClassroomCodeShare'
@@ -12,14 +22,63 @@ interface StudentManagerTabProps {
/**
* StudentManagerTab - Manage enrolled students
*
* Shows all students enrolled in the classroom.
* For Phase 3, this is an empty state.
* Phase 4 will add enrollment functionality.
* Shows all students enrolled in the classroom with options to:
* - View student progress (links to student page)
* - Approve/deny pending enrollment requests
* - Unenroll students
*/
export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Fetch enrolled students and pending requests
const { data: students = [], isLoading: loadingStudents } = useEnrolledStudents(classroom.id)
const { data: pendingRequests = [], isLoading: loadingRequests } = usePendingEnrollmentRequests(
classroom.id
)
// Mutations
const approveRequest = useApproveEnrollmentRequest()
const denyRequest = useDenyEnrollmentRequest()
const unenrollStudent = useUnenrollStudent()
// Confirmation state
const [confirmUnenroll, setConfirmUnenroll] = useState<string | null>(null)
const handleApprove = useCallback(
(request: EnrollmentRequestWithRelations) => {
approveRequest.mutate({
classroomId: classroom.id,
requestId: request.id,
})
},
[approveRequest, classroom.id]
)
const handleDeny = useCallback(
(request: EnrollmentRequestWithRelations) => {
denyRequest.mutate({
classroomId: classroom.id,
requestId: request.id,
})
},
[denyRequest, classroom.id]
)
const handleUnenroll = useCallback(
(playerId: string) => {
unenrollStudent.mutate({
classroomId: classroom.id,
playerId,
})
setConfirmUnenroll(null)
},
[unenrollStudent, classroom.id]
)
const isLoading = loadingStudents || loadingRequests
const isEmpty = students.length === 0 && pendingRequests.length === 0
return (
<div
data-component="student-manager-tab"
@@ -29,88 +88,433 @@ export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
gap: '24px',
})}
>
{/* Pending Enrollment Requests */}
{pendingRequests.length > 0 && (
<section
data-section="pending-requests"
className={css({
padding: '20px',
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.200',
})}
>
<h3
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'yellow.300' : 'yellow.700',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span>Pending Enrollment Requests</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '22px',
height: '22px',
padding: '0 6px',
borderRadius: '11px',
backgroundColor: isDark ? 'yellow.700' : 'yellow.500',
color: 'white',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
{pendingRequests.length}
</span>
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{pendingRequests.map((request) => (
<EnrollmentRequestCard
key={request.id}
request={request}
onApprove={() => handleApprove(request)}
onDeny={() => handleDeny(request)}
isApproving={
approveRequest.isPending && approveRequest.variables?.requestId === request.id
}
isDenying={denyRequest.isPending && denyRequest.variables?.requestId === request.id}
isDark={isDark}
/>
))}
</div>
</section>
)}
{/* Enrolled Students */}
{students.length > 0 && (
<section data-section="enrolled-students">
<h3
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '16px',
})}
>
Enrolled Students ({students.length})
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{students.map((student) => (
<EnrolledStudentCard
key={student.id}
student={student}
onUnenroll={() => setConfirmUnenroll(student.id)}
isConfirming={confirmUnenroll === student.id}
onConfirmUnenroll={() => handleUnenroll(student.id)}
onCancelUnenroll={() => setConfirmUnenroll(null)}
isUnenrolling={
unenrollStudent.isPending && unenrollStudent.variables?.playerId === student.id
}
isDark={isDark}
/>
))}
</div>
</section>
)}
{/* Empty state */}
<div
className={css({
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{isEmpty && !isLoading && (
<div
className={css({
fontSize: '3rem',
marginBottom: '16px',
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
👥
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Enrolled
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
Parents can enroll their children using your classroom code. Once enrolled, you can view
their progress and skills here.
</p>
<div className={css({ fontSize: '3rem', marginBottom: '16px' })}>👥</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Enrolled
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
Parents can enroll their children using your classroom code. Once enrolled, you can view
their progress and skills here.
</p>
<ClassroomCodeShare code={classroom.code} />
<ClassroomCodeShare code={classroom.code} />
</div>
)}
{/* Info section */}
{students.length > 0 && (
<div
className={css({
padding: '16px 20px',
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'blue.200' : 'blue.700',
})}
>
Click on a student to view their skills and practice history. Need more students? Share
your classroom code: <strong>{classroom.code}</strong>
</p>
</div>
)}
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
interface EnrollmentRequestCardProps {
request: EnrollmentRequestWithRelations
onApprove: () => void
onDeny: () => void
isApproving: boolean
isDenying: boolean
isDark: boolean
}
function EnrollmentRequestCard({
request,
onApprove,
onDeny,
isApproving,
isDenying,
isDark,
}: EnrollmentRequestCardProps) {
const player = request.player
return (
<div
data-element="enrollment-request-card"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '10px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: player?.color ?? '#ccc' }}
>
{player?.emoji ?? '?'}
</span>
<div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{player?.name ?? 'Unknown Student'}
</p>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Enrollment requested by parent
</p>
</div>
</div>
{/* What you'll see when students enroll */}
<div
className={css({
padding: '20px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h4
className={css({
fontSize: '0.9375rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '12px',
})}
>
When students enroll, you can:
</h4>
<ul
<div className={css({ display: 'flex', gap: '8px' })}>
<button
type="button"
onClick={onDeny}
disabled={isDenying || isApproving}
data-action="deny-enrollment"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
paddingLeft: '20px',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '6px',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
<li>View each student's skill mastery and progress</li>
<li>See their practice session history</li>
<li>Observe live practice sessions</li>
<li>Guide them through tutorials remotely</li>
</ul>
{isDenying ? 'Denying...' : 'Deny'}
</button>
<button
type="button"
onClick={onApprove}
disabled={isApproving || isDenying}
data-action="approve-enrollment"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'green.600' : 'green.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isApproving ? 'Approving...' : 'Approve'}
</button>
</div>
</div>
)
}
interface EnrolledStudentCardProps {
student: Player
onUnenroll: () => void
isConfirming: boolean
onConfirmUnenroll: () => void
onCancelUnenroll: () => void
isUnenrolling: boolean
isDark: boolean
}
function EnrolledStudentCard({
student,
onUnenroll,
isConfirming,
onConfirmUnenroll,
onCancelUnenroll,
isUnenrolling,
isDark,
}: EnrolledStudentCardProps) {
return (
<div
data-element="enrolled-student-card"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '10px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
transition: 'all 0.15s ease',
})}
>
<Link
href={`/practice/${student.id}/resume`}
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
textDecoration: 'none',
flex: 1,
_hover: { opacity: 0.8 },
})}
>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{student.name}
</p>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
View progress
</p>
</div>
</Link>
{/* Unenroll actions */}
<div className={css({ display: 'flex', gap: '8px' })}>
{isConfirming ? (
<>
<button
type="button"
onClick={onCancelUnenroll}
disabled={isUnenrolling}
data-action="cancel-unenroll"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
Cancel
</button>
<button
type="button"
onClick={onConfirmUnenroll}
disabled={isUnenrolling}
data-action="confirm-unenroll"
className={css({
padding: '8px 14px',
backgroundColor: isDark ? 'red.700' : 'red.500',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'red.600' : 'red.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isUnenrolling ? 'Removing...' : 'Confirm'}
</button>
</>
) : (
<button
type="button"
onClick={onUnenroll}
data-action="unenroll-student"
className={css({
padding: '8px 14px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
color: isDark ? 'gray.300' : 'gray.700',
},
})}
>
Unenroll
</button>
)}
</div>
</div>
)

View File

@@ -2,4 +2,5 @@ export { ClassroomCodeShare } from './ClassroomCodeShare'
export { ClassroomDashboard } from './ClassroomDashboard'
export { ClassroomTab } from './ClassroomTab'
export { CreateClassroomForm } from './CreateClassroomForm'
export { EnrollChildFlow } from './EnrollChildFlow'
export { StudentManagerTab } from './StudentManagerTab'

View File

@@ -1,7 +1,7 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Classroom, User } from '@/db/schema'
import type { Classroom, EnrollmentRequest, Player, User } from '@/db/schema'
import { api } from '@/lib/queryClient'
import { classroomKeys } from '@/lib/queryKeys'
@@ -16,6 +16,11 @@ export interface ClassroomWithTeacher extends Classroom {
teacher?: User
}
export interface EnrollmentRequestWithRelations extends EnrollmentRequest {
player?: Player
classroom?: Classroom
}
// ============================================================================
// API Functions
// ============================================================================
@@ -118,3 +123,203 @@ export function useIsTeacher() {
isLoading,
}
}
// ============================================================================
// Enrollment API Functions
// ============================================================================
/**
* Fetch enrolled students for a classroom
*/
async function fetchEnrolledStudents(classroomId: string): Promise<Player[]> {
const res = await api(`classrooms/${classroomId}/enrollments`)
if (!res.ok) throw new Error('Failed to fetch enrolled students')
const data = await res.json()
return data.students
}
/**
* Fetch pending enrollment requests for a classroom
*/
async function fetchPendingRequests(
classroomId: string
): Promise<EnrollmentRequestWithRelations[]> {
const res = await api(`classrooms/${classroomId}/enrollment-requests`)
if (!res.ok) throw new Error('Failed to fetch enrollment requests')
const data = await res.json()
return data.requests
}
/**
* Create enrollment request
*/
async function createEnrollmentRequest(params: {
classroomId: string
playerId: string
}): Promise<EnrollmentRequest> {
const res = await api(`classrooms/${params.classroomId}/enrollment-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerId: params.playerId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to create enrollment request')
}
const data = await res.json()
return data.request
}
/**
* Approve enrollment request
*/
async function approveRequest(params: {
classroomId: string
requestId: string
}): Promise<{ request: EnrollmentRequest; fullyApproved: boolean }> {
const res = await api(
`classrooms/${params.classroomId}/enrollment-requests/${params.requestId}/approve`,
{ method: 'POST' }
)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to approve request')
}
return res.json()
}
/**
* Deny enrollment request
*/
async function denyRequest(params: {
classroomId: string
requestId: string
}): Promise<EnrollmentRequest> {
const res = await api(
`classrooms/${params.classroomId}/enrollment-requests/${params.requestId}/deny`,
{ method: 'POST' }
)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to deny request')
}
const data = await res.json()
return data.request
}
/**
* Unenroll student from classroom
*/
async function unenrollStudent(params: { classroomId: string; playerId: string }): Promise<void> {
const res = await api(`classrooms/${params.classroomId}/enrollments/${params.playerId}`, {
method: 'DELETE',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to unenroll student')
}
}
// ============================================================================
// Enrollment Hooks
// ============================================================================
/**
* Get enrolled students in a classroom
*/
export function useEnrolledStudents(classroomId: string | undefined) {
return useQuery({
queryKey: classroomKeys.enrollments(classroomId ?? ''),
queryFn: () => fetchEnrolledStudents(classroomId!),
enabled: !!classroomId,
staleTime: 60 * 1000, // 1 minute
})
}
/**
* Get pending enrollment requests for a classroom
*/
export function usePendingEnrollmentRequests(classroomId: string | undefined) {
return useQuery({
queryKey: [...classroomKeys.detail(classroomId ?? ''), 'pending-requests'],
queryFn: () => fetchPendingRequests(classroomId!),
enabled: !!classroomId,
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Create enrollment request mutation
*
* Used by parents to request enrollment of their child in a classroom.
*/
export function useCreateEnrollmentRequest() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createEnrollmentRequest,
onSuccess: (_, { classroomId }) => {
// Invalidate pending requests for this classroom
queryClient.invalidateQueries({
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
})
},
})
}
/**
* Approve enrollment request mutation (for teachers)
*/
export function useApproveEnrollmentRequest() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: approveRequest,
onSuccess: (result, { classroomId }) => {
// Invalidate pending requests
queryClient.invalidateQueries({
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
})
// If fully approved, also invalidate enrollments
if (result.fullyApproved) {
queryClient.invalidateQueries({
queryKey: classroomKeys.enrollments(classroomId),
})
}
},
})
}
/**
* Deny enrollment request mutation (for teachers)
*/
export function useDenyEnrollmentRequest() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: denyRequest,
onSuccess: (_, { classroomId }) => {
// Invalidate pending requests
queryClient.invalidateQueries({
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
})
},
})
}
/**
* Unenroll student mutation
*/
export function useUnenrollStudent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: unenrollStudent,
onSuccess: (_, { classroomId }) => {
// Invalidate enrollments
queryClient.invalidateQueries({
queryKey: classroomKeys.enrollments(classroomId),
})
},
})
}