diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts index c34bf220..11140a32 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts @@ -1,7 +1,8 @@ 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 { getLinkedParentIds, getTeacherClassroom, isParent, unenrollStudent } from '@/lib/classroom' +import { emitStudentUnenrolled } from '@/lib/classroom/socket-emitter' import { getViewerId } from '@/lib/viewer' /** @@ -50,6 +51,44 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { await unenrollStudent(classroomId, playerId) + // Emit socket event for real-time updates + try { + // Get classroom and player info for socket event + const [classroomInfo] = await db + .select({ name: schema.classrooms.name }) + .from(schema.classrooms) + .where(eq(schema.classrooms.id, classroomId)) + .limit(1) + + const [playerInfo] = await db + .select({ name: schema.players.name }) + .from(schema.players) + .where(eq(schema.players.id, playerId)) + .limit(1) + + if (classroomInfo && playerInfo) { + // Get parent IDs to notify + const parentIds = await getLinkedParentIds(playerId) + + await emitStudentUnenrolled( + { + classroomId, + classroomName: classroomInfo.name, + playerId, + playerName: playerInfo.name, + unenrolledBy: isTeacher ? 'teacher' : 'parent', + }, + { + classroomId, // Teacher sees student removed + userIds: parentIds, // Parents see child is no longer enrolled + playerIds: [playerId], // Student sees they're no longer in classroom + } + ) + } + } catch (socketError) { + console.error('[Unenroll] Failed to emit socket event:', socketError) + } + return NextResponse.json({ success: true }) } catch (error) { console.error('Failed to unenroll student:', error) diff --git a/apps/web/src/components/classroom/EnrollChildModal.tsx b/apps/web/src/components/classroom/EnrollChildModal.tsx new file mode 100644 index 00000000..5d31e76e --- /dev/null +++ b/apps/web/src/components/classroom/EnrollChildModal.tsx @@ -0,0 +1,502 @@ +'use client' + +import * as Dialog from '@radix-ui/react-dialog' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Z_INDEX } from '@/constants/zIndex' +import { useTheme } from '@/contexts/ThemeContext' +import { + useClassroomByCode, + useCreateEnrollmentRequest, + useEnrolledClassrooms, +} from '@/hooks/useClassroom' +import { css } from '../../../styled-system/css' + +interface EnrollChildModalProps { + isOpen: boolean + onClose: () => void + playerId: string + playerName: string +} + +/** + * Modal for enrolling a specific child in a classroom + * + * Flow: + * 1. Enter classroom code + * 2. See classroom info (name, teacher) + * 3. Submit enrollment request + * 4. Success message + * + * Unlike EnrollChildFlow, this modal already knows which child + * to enroll (playerId/playerName passed as props). + */ +export function EnrollChildModal({ isOpen, onClose, playerId, playerName }: EnrollChildModalProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + // Form state + const [code, setCode] = useState('') + + // Look up classroom by code + const { data: classroom, isLoading: lookingUp } = useClassroomByCode(code) + + // Enrollment mutation + const createRequest = useCreateEnrollmentRequest() + + // Check enrolled classrooms to detect if teacher already approved + const { data: enrolledClassrooms } = useEnrolledClassrooms(playerId) + + // Check if the classroom we just requested enrollment for is now in the enrolled list + // This means the teacher approved it while we were waiting + const isNowEnrolled = + createRequest.isSuccess && classroom && enrolledClassrooms?.some((c) => c.id === classroom.id) + + // Auto-close countdown when enrollment is complete + const AUTO_CLOSE_SECONDS = 3 + const [countdown, setCountdown] = useState(null) + const countdownStartedRef = useRef(false) + const [isClosing, setIsClosing] = useState(false) + + // Reset state when modal closes + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + // Start closing animation + setIsClosing(true) + // After animation completes, actually close + setTimeout(() => { + setCode('') + setCountdown(null) + countdownStartedRef.current = false + setIsClosing(false) + createRequest.reset() + onClose() + }, 200) // Match CSS transition duration + } + }, + [onClose, createRequest] + ) + + // Start countdown when enrollment is confirmed + useEffect(() => { + if (isNowEnrolled && !countdownStartedRef.current) { + countdownStartedRef.current = true + setCountdown(AUTO_CLOSE_SECONDS) + } + }, [isNowEnrolled]) + + // Tick down the countdown + useEffect(() => { + if (countdown === null || countdown <= 0) return + + const timer = setTimeout(() => { + setCountdown((c) => (c !== null ? c - 1 : null)) + }, 1000) + + return () => clearTimeout(timer) + }, [countdown]) + + // Auto-close when countdown reaches 0 + useEffect(() => { + if (countdown === 0) { + handleOpenChange(false) + } + }, [countdown, handleOpenChange]) + + const handleSubmit = useCallback(async () => { + if (!classroom) return + + try { + await createRequest.mutateAsync({ + classroomId: classroom.id, + playerId, + }) + } catch { + // Error handled by mutation state + } + }, [classroom, playerId, createRequest]) + + return ( + + + + + + Enroll {playerName} in a Classroom + + + Enter the classroom code from the teacher. + + + {/* Success state */} + {createRequest.isSuccess ? ( +
+
+ {isNowEnrolled ? ( + <> +

+ Enrolled! +

+

+ {playerName} is now enrolled in {classroom?.name}. +

+ + ) : ( + <> +

+ Enrollment Request Sent! +

+

+ The teacher will review and approve the enrollment. +

+ + )} +
+ + +
+ ) : ( + <> + {/* Code input */} +
+ + setCode(e.target.value.toUpperCase())} + placeholder="e.g., ABC123" + maxLength={6} + autoFocus + 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 && ( +
+ {lookingUp && ( +

+ Looking up classroom... +

+ )} + + {!lookingUp && !classroom && code.length >= 6 && ( +

+ No classroom found with this code +

+ )} +
+ )} +
+ + {/* Classroom found */} + {classroom && ( +
+
+ 🏫 +
+

+ {classroom.name} +

+

+ Teacher:{' '} + {(classroom as { teacher?: { name?: string } }).teacher?.name ?? 'Teacher'} +

+
+
+
+ )} + + {/* Error display */} + {createRequest.error && ( +

+ {createRequest.error.message} +

+ )} + + {/* Actions */} +
+ + + +
+ + )} + + {/* Close button */} + + + +
+
+
+ ) +} diff --git a/apps/web/src/hooks/useClassroomSocket.ts b/apps/web/src/hooks/useClassroomSocket.ts index 0bf7d041..628026b0 100644 --- a/apps/web/src/hooks/useClassroomSocket.ts +++ b/apps/web/src/hooks/useClassroomSocket.ts @@ -11,6 +11,7 @@ import type { EnrollmentRequestDeniedEvent, StudentEnteredEvent, StudentLeftEvent, + StudentUnenrolledEvent, } from '@/lib/classroom/socket-events' /** @@ -99,6 +100,20 @@ export function useClassroomSocket(classroomId: string | undefined): { connected }) }) + // Listen for student unenrolled event + socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => { + console.log( + '[ClassroomSocket] Student unenrolled:', + data.playerName, + 'by:', + data.unenrolledBy + ) + invalidateForEvent(queryClient, 'studentUnenrolled', { + classroomId, + playerId: data.playerId, + }) + }) + // Cleanup on unmount return () => { socket.emit('leave-classroom', { classroomId }) diff --git a/apps/web/src/hooks/useParentSocket.ts b/apps/web/src/hooks/useParentSocket.ts index 27992059..f17d6cb9 100644 --- a/apps/web/src/hooks/useParentSocket.ts +++ b/apps/web/src/hooks/useParentSocket.ts @@ -9,6 +9,7 @@ import type { EnrollmentRequestApprovedEvent, EnrollmentRequestCreatedEvent, EnrollmentRequestDeniedEvent, + StudentUnenrolledEvent, } from '@/lib/classroom/socket-events' /** @@ -101,6 +102,15 @@ export function useParentSocket(userId: string | undefined): { connected: boolea }) }) + // Listen for student unenrolled event (child removed from classroom) + socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => { + console.log('[ParentSocket] Child unenrolled:', data.playerName, 'from:', data.classroomName) + invalidateForEvent(queryClient, 'studentUnenrolled', { + classroomId: data.classroomId, + playerId: data.playerId, + }) + }) + // Cleanup on unmount return () => { socket.disconnect() diff --git a/apps/web/src/hooks/usePlayerPresenceSocket.ts b/apps/web/src/hooks/usePlayerPresenceSocket.ts index d9128ec3..304659c7 100644 --- a/apps/web/src/hooks/usePlayerPresenceSocket.ts +++ b/apps/web/src/hooks/usePlayerPresenceSocket.ts @@ -3,7 +3,12 @@ import { useEffect, useRef, useState } from 'react' import { io, type Socket } from 'socket.io-client' import { useQueryClient } from '@tanstack/react-query' -import type { PresenceRemovedEvent } from '@/lib/classroom/socket-events' +import { invalidateForEvent } from '@/lib/classroom/query-invalidations' +import type { + EnrollmentApprovedEvent, + PresenceRemovedEvent, + StudentUnenrolledEvent, +} from '@/lib/classroom/socket-events' /** * Hook for real-time player presence updates via WebSocket @@ -53,6 +58,24 @@ export function usePlayerPresenceSocket(playerId: string | undefined): { connect }) }) + // Listen for student unenrolled event (student removed from classroom entirely) + socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => { + console.log('[PlayerPresenceSocket] Student unenrolled from:', data.classroomName) + invalidateForEvent(queryClient, 'studentUnenrolled', { + classroomId: data.classroomId, + playerId, + }) + }) + + // Listen for enrollment completed event (student now enrolled in classroom) + socket.on('enrollment-approved', (data: EnrollmentApprovedEvent) => { + console.log('[PlayerPresenceSocket] Enrolled in classroom:', data.classroomName) + invalidateForEvent(queryClient, 'enrollmentCompleted', { + classroomId: data.classroomId, + playerId, + }) + }) + // Cleanup on unmount return () => { socket.emit('leave-player', { playerId }) diff --git a/apps/web/src/lib/classroom/query-invalidations.ts b/apps/web/src/lib/classroom/query-invalidations.ts index c3968991..3f23dcd8 100644 --- a/apps/web/src/lib/classroom/query-invalidations.ts +++ b/apps/web/src/lib/classroom/query-invalidations.ts @@ -11,6 +11,7 @@ * - requestApproved: One side (teacher or parent) approved their part of the request * - requestDenied: A request was denied by either side * - enrollmentCompleted: Both sides approved, student is now fully enrolled + * - studentUnenrolled: Student was removed from a classroom (by teacher or parent) * - studentEntered: Student entered a classroom * - studentLeft: Student left a classroom */ @@ -26,6 +27,7 @@ export type ClassroomEventType = | 'requestApproved' | 'requestDenied' | 'enrollmentCompleted' + | 'studentUnenrolled' | 'studentEntered' | 'studentLeft' @@ -128,6 +130,27 @@ export function invalidateForEvent( }) break + case 'studentUnenrolled': + // Teacher sees student removed from enrollments and presence + if (classroomId) { + queryClient.invalidateQueries({ + queryKey: classroomKeys.enrollments(classroomId), + }) + queryClient.invalidateQueries({ + queryKey: classroomKeys.presence(classroomId), + }) + } + // Student/parent sees classroom removed from enrolled list and presence cleared + if (playerId) { + queryClient.invalidateQueries({ + queryKey: playerKeys.enrolledClassrooms(playerId), + }) + queryClient.invalidateQueries({ + queryKey: playerKeys.presence(playerId), + }) + } + break + case 'studentEntered': // Teacher sees updated presence if (classroomId) { @@ -202,6 +225,17 @@ export function getInvalidationKeys( keys.push(classroomKeys.pendingParentApprovals()) break + case 'studentUnenrolled': + if (classroomId) { + keys.push(classroomKeys.enrollments(classroomId)) + keys.push(classroomKeys.presence(classroomId)) + } + if (playerId) { + keys.push(playerKeys.enrolledClassrooms(playerId)) + keys.push(playerKeys.presence(playerId)) + } + break + case 'studentEntered': if (classroomId) { keys.push(classroomKeys.presence(classroomId)) diff --git a/apps/web/src/lib/classroom/socket-emitter.ts b/apps/web/src/lib/classroom/socket-emitter.ts index 82fbffa9..c065949f 100644 --- a/apps/web/src/lib/classroom/socket-emitter.ts +++ b/apps/web/src/lib/classroom/socket-emitter.ts @@ -16,6 +16,7 @@ import type { EnrollmentRequestApprovedEvent, EnrollmentRequestCreatedEvent, EnrollmentRequestDeniedEvent, + StudentUnenrolledEvent, } from './socket-events' /** @@ -138,6 +139,30 @@ export async function emitEnrollmentCompleted( emitToRecipients(io, 'enrollment-approved', eventData, recipients, 'enrollment-completed') } +/** + * Emit a student unenrolled event + * + * Use when: A student is removed from a classroom (by teacher or parent). + * This also implies their presence was removed if they were in the classroom. + */ +export async function emitStudentUnenrolled( + payload: Omit & { unenrolledBy: 'teacher' | 'parent' }, + recipients: SocketRecipients +): Promise { + const io = await getSocketIO() + if (!io) return + + const eventData: StudentUnenrolledEvent = { + classroomId: payload.classroomId, + classroomName: payload.classroomName, + playerId: payload.playerId, + playerName: payload.playerName, + unenrolledBy: payload.unenrolledBy, + } + + emitToRecipients(io, 'student-unenrolled', eventData, recipients, 'student-unenrolled') +} + /** * Internal helper to emit to all specified recipients */ diff --git a/apps/web/src/lib/classroom/socket-events.ts b/apps/web/src/lib/classroom/socket-events.ts index a86cb69a..860deb0c 100644 --- a/apps/web/src/lib/classroom/socket-events.ts +++ b/apps/web/src/lib/classroom/socket-events.ts @@ -68,6 +68,23 @@ export interface EnrollmentApprovedEvent { playerName: string } +/** + * Sent when a student is removed from a classroom (unenrolled). + * This also removes their presence if they were in the classroom. + * + * Sent to: + * - classroom:${classroomId} - Teacher sees student removed + * - player:${playerId} - Student sees they're no longer enrolled + * - user:${parentIds} - Parents see child is no longer enrolled + */ +export interface StudentUnenrolledEvent { + classroomId: string + classroomName: string + playerId: string + playerName: string + unenrolledBy: 'teacher' | 'parent' +} + /** * @deprecated Use EnrollmentRequestDeniedEvent instead */ @@ -158,6 +175,7 @@ export interface ClassroomServerToClientEvents { 'enrollment-request-approved': (data: EnrollmentRequestApprovedEvent) => void 'enrollment-request-denied': (data: EnrollmentRequestDeniedEvent) => void 'enrollment-approved': (data: EnrollmentApprovedEvent) => void + 'student-unenrolled': (data: StudentUnenrolledEvent) => void 'enrollment-denied': (data: EnrollmentDeniedEvent) => void // deprecated // Presence events (classroom channel)