feat(classroom): implement real-time presence with WebSocket (Phase 6)
- Add classroom presence management (enter/leave classroom) - Implement WebSocket events for real-time presence updates - Add useClassroomSocket hook for teachers to receive student enter/leave events - Add usePlayerPresenceSocket hook for students to receive removal notifications - Create EnterClassroomButton component for students to join classroom - Add PendingApprovalsSection for parents to approve enrollment requests - Fix React Query cache invalidation for enter/leave mutations - Move socket subscription to ClassroomDashboard for cross-tab persistence - Add getDbUserId() helper to fix viewerId vs user.id bug pattern - Delete legacy configure/resume pages (consolidated into dashboard) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,8 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
}
|
||||
|
||||
await leaveSpecificClassroom(playerId, classroomId)
|
||||
// Pass 'teacher' if removed by teacher, 'self' otherwise (parent removing their child)
|
||||
await leaveSpecificClassroom(playerId, classroomId, isTeacher ? 'teacher' : 'self')
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { approveEnrollmentRequest, isParent } from '@/lib/classroom'
|
||||
import { db } from '@/db'
|
||||
import { enrollmentRequests } from '@/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { enrollmentRequests } from '@/db/schema'
|
||||
import { approveEnrollmentRequest, 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<{ requestId: string }>
|
||||
}
|
||||
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { requestId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Get the request to verify parent owns the child
|
||||
const request = await db.query.enrollmentRequests.findFirst({
|
||||
@@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Verify user is a parent of the child in the request
|
||||
const parentCheck = await isParent(viewerId, request.playerId)
|
||||
const parentCheck = await isParent(user.id, request.playerId)
|
||||
if (!parentCheck) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await approveEnrollmentRequest(requestId, viewerId, 'parent')
|
||||
const result = await approveEnrollmentRequest(requestId, user.id, 'parent')
|
||||
|
||||
return NextResponse.json({
|
||||
request: result.request,
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { denyEnrollmentRequest, isParent } from '@/lib/classroom'
|
||||
import { db } from '@/db'
|
||||
import { enrollmentRequests } from '@/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { enrollmentRequests } from '@/db/schema'
|
||||
import { denyEnrollmentRequest, 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<{ requestId: string }>
|
||||
}
|
||||
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { requestId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Get the request to verify parent owns the child
|
||||
const request = await db.query.enrollmentRequests.findFirst({
|
||||
@@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Verify user is a parent of the child in the request
|
||||
const parentCheck = await isParent(viewerId, request.playerId)
|
||||
const parentCheck = await isParent(user.id, request.playerId)
|
||||
if (!parentCheck) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updatedRequest = await denyEnrollmentRequest(requestId, viewerId, 'parent')
|
||||
const updatedRequest = await denyEnrollmentRequest(requestId, user.id, 'parent')
|
||||
|
||||
return NextResponse.json({ request: updatedRequest })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getPendingRequestsForParent } 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
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/enrollment-requests/pending
|
||||
* Get enrollment requests pending current user's approval as parent
|
||||
*
|
||||
* Returns: { requests: EnrollmentRequest[] }
|
||||
* These are requests initiated by teachers for the user's children,
|
||||
* where parent approval hasn't been given yet.
|
||||
*
|
||||
* Returns: { requests: EnrollmentRequestWithRelations[] }
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
const requests = await getPendingRequestsForParent(viewerId)
|
||||
const requests = await getPendingRequestsForParent(user.id)
|
||||
|
||||
return NextResponse.json({ requests })
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEnrolledClassrooms, canPerformAction } from '@/lib/classroom'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/players/[id]/enrolled-classrooms
|
||||
* Get classrooms that this student is enrolled in
|
||||
*
|
||||
* Returns: { classrooms: Classroom[] }
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id: playerId } = await params
|
||||
const userId = await getDbUserId()
|
||||
|
||||
// Check authorization: must have at least view access
|
||||
const canView = await canPerformAction(userId, playerId, 'view')
|
||||
if (!canView) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const classrooms = await getEnrolledClassrooms(playerId)
|
||||
|
||||
return NextResponse.json({ classrooms })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch enrolled classrooms:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch enrolled classrooms' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getStudentPresence, canPerformAction } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -15,10 +15,10 @@ interface RouteParams {
|
||||
export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id: playerId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const userId = await getDbUserId()
|
||||
|
||||
// Check authorization: must have at least view access
|
||||
const canView = await canPerformAction(viewerId, playerId, 'view')
|
||||
const canView = await canPerformAction(userId, playerId, 'view')
|
||||
if (!canView) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom'
|
||||
import {
|
||||
ClassroomDashboard,
|
||||
CreateClassroomForm,
|
||||
EnrollChildFlow,
|
||||
PendingApprovalsSection,
|
||||
} from '@/components/classroom'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
|
||||
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
|
||||
@@ -133,7 +138,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
router.push(`/practice/${student.id}/resume`, { scroll: false })
|
||||
router.push(`/practice/${student.id}/dashboard`, { scroll: false })
|
||||
}
|
||||
},
|
||||
[router, editMode]
|
||||
@@ -394,6 +399,9 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Pending Enrollment Approvals - for parents to approve teacher-initiated requests */}
|
||||
<PendingApprovalsSection />
|
||||
|
||||
{/* Needs Attention Section - uses same bucket styling as other sections */}
|
||||
{studentsNeedingAttention.length > 0 && (
|
||||
<div data-bucket="attention" data-component="needs-attention-bucket">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Disable caching - session data should be fresh
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface ConfigurePageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Practice Session Page - DEPRECATED
|
||||
*
|
||||
* This page now redirects to the dashboard. The session configuration
|
||||
* modal is accessible from the dashboard via the "Start Practice" button.
|
||||
*
|
||||
* URL: /practice/[studentId]/configure → redirects to /practice/[studentId]/dashboard
|
||||
*/
|
||||
export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// Redirect to dashboard - the StartPracticeModal is now accessible from there
|
||||
redirect(`/practice/${studentId}/dashboard`)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { EnterClassroomButton } from '@/components/classroom'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import {
|
||||
type ActiveSessionState,
|
||||
@@ -30,6 +31,7 @@ import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import type { PracticeSession } from '@/db/schema/practice-sessions'
|
||||
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||
import { usePlayerPresenceSocket } from '@/hooks/usePlayerPresenceSocket'
|
||||
import { useSessionMode } from '@/hooks/useSessionMode'
|
||||
import type { SessionMode } from '@/lib/curriculum/session-mode'
|
||||
import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum'
|
||||
@@ -2522,6 +2524,10 @@ export function DashboardClient({
|
||||
// Session mode - single source of truth for session planning decisions
|
||||
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
|
||||
|
||||
// Subscribe to player presence updates via WebSocket
|
||||
// This ensures the UI updates when teacher removes student from classroom
|
||||
usePlayerPresenceSocket(studentId)
|
||||
|
||||
// Tab state - sync with URL
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab)
|
||||
|
||||
@@ -2687,6 +2693,17 @@ export function DashboardClient({
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
|
||||
{/* Classroom presence - allows entering enrolled classrooms for live practice */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<EnterClassroomButton playerId={studentId} playerName={player.name} />
|
||||
</div>
|
||||
|
||||
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
|
||||
<ContentBannerSlot
|
||||
stickyOffset={STICKY_HEADER_OFFSET}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface StudentPracticePageProps {
|
||||
*
|
||||
* Guards/Redirects:
|
||||
* - No active session → /dashboard (show progress, start new session)
|
||||
* - Draft/approved session (not started) → /configure (approve and start)
|
||||
* - Draft/approved session (not started) → /dashboard (modal handles configuration)
|
||||
* - In_progress session → SHOW PROBLEM (this is the only state we render here)
|
||||
* - Completed session → /summary (show results)
|
||||
*
|
||||
@@ -42,9 +42,9 @@ export default async function StudentPracticePage({ params }: StudentPracticePag
|
||||
redirect(`/practice/${studentId}/dashboard`)
|
||||
}
|
||||
|
||||
// Draft or approved but not started → configure page
|
||||
// Draft or approved but not started → dashboard (modal handles configuration)
|
||||
if (!activeSession.startedAt) {
|
||||
redirect(`/practice/${studentId}/configure`)
|
||||
redirect(`/practice/${studentId}/dashboard`)
|
||||
}
|
||||
|
||||
// Session is completed → summary page
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ContinueSessionCard, PracticeSubNav } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { Player } from '@/db/schema/players'
|
||||
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||
import { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
interface ResumeClientProps {
|
||||
studentId: string
|
||||
player: Player
|
||||
initialSession: SessionPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component for the Resume page
|
||||
*
|
||||
* Shows the "Welcome back" card for students returning to an in-progress session.
|
||||
* Uses React Query to get the most up-to-date session data (from cache if available,
|
||||
* otherwise uses server-provided initial data).
|
||||
*/
|
||||
export function ResumeClient({ studentId, player, initialSession }: ResumeClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const abandonSession = useAbandonSession()
|
||||
|
||||
// Use React Query to get fresh session data
|
||||
// If there's cached data from in-progress session, use that; otherwise use server props
|
||||
const { data: fetchedSession } = useActiveSessionPlan(studentId, initialSession)
|
||||
const session = fetchedSession ?? initialSession
|
||||
|
||||
// Handle continuing the session - navigate to main practice page
|
||||
const handleContinue = useCallback(() => {
|
||||
router.push(`/practice/${studentId}`, { scroll: false })
|
||||
}, [studentId, router])
|
||||
|
||||
// Handle starting fresh - abandon current session and go to configure
|
||||
const handleStartFresh = useCallback(() => {
|
||||
abandonSession.mutate(
|
||||
{ playerId: studentId, planId: session.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push(`/practice/${studentId}/configure`, { scroll: false })
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [studentId, session.id, abandonSession, router])
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
{/* Practice Sub-Navigation */}
|
||||
<PracticeSubNav student={player} pageContext="resume" />
|
||||
|
||||
<main
|
||||
data-component="resume-practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: '2rem',
|
||||
paddingLeft: '2rem',
|
||||
paddingRight: '2rem',
|
||||
paddingBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Welcome Back!
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Continue where you left off
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Continue Session Card */}
|
||||
<ContinueSessionCard
|
||||
studentName={player.name}
|
||||
studentEmoji={player.emoji}
|
||||
studentColor={player.color}
|
||||
session={session}
|
||||
onContinue={handleContinue}
|
||||
onStartFresh={handleStartFresh}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Disable caching for this page
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface ResumePageProps {
|
||||
params: Promise<{ studentId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume Session Page - DEPRECATED
|
||||
*
|
||||
* This page now redirects to the main practice page. The "welcome back"
|
||||
* experience is now handled by the SessionPausedModal which shows automatically
|
||||
* when returning to an in-progress session.
|
||||
*
|
||||
* URL: /practice/[studentId]/resume → redirects to /practice/[studentId]
|
||||
*/
|
||||
export default async function ResumePage({ params }: ResumePageProps) {
|
||||
const { studentId } = await params
|
||||
|
||||
// The main practice page now handles the "welcome back" modal
|
||||
redirect(`/practice/${studentId}`)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { Classroom, Player } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useClassroomSocket } from '@/hooks/useClassroomSocket'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
import { ClassroomTab } from './ClassroomTab'
|
||||
@@ -32,6 +33,10 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
|
||||
const [activeTab, setActiveTab] = useState<TabId>('classroom')
|
||||
const [showEnrollChild, setShowEnrollChild] = useState(false)
|
||||
|
||||
// Subscribe to real-time classroom presence updates via WebSocket
|
||||
// This is at the dashboard level so events are received even when on other tabs
|
||||
useClassroomSocket(classroom.id)
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: string }[] = [
|
||||
{ id: 'classroom', label: 'Classroom', icon: '🏫' },
|
||||
{ id: 'students', label: 'Student Manager', icon: '👥' },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Classroom } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useClassroomPresence, useLeaveClassroom, type PresenceStudent } from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
|
||||
@@ -13,13 +16,28 @@ interface ClassroomTabProps {
|
||||
* ClassroomTab - Shows live classroom view
|
||||
*
|
||||
* Displays students currently "present" in the classroom.
|
||||
* For Phase 3, this is an empty state.
|
||||
* Phase 6 will add presence functionality.
|
||||
* Teachers can see who's actively practicing and remove students when needed.
|
||||
*/
|
||||
export function ClassroomTab({ classroom }: ClassroomTabProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Fetch present students
|
||||
// Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays
|
||||
// connected even when user switches tabs
|
||||
const { data: presentStudents = [], isLoading } = useClassroomPresence(classroom.id)
|
||||
const leaveClassroom = useLeaveClassroom()
|
||||
|
||||
const handleRemoveStudent = useCallback(
|
||||
(playerId: string) => {
|
||||
leaveClassroom.mutate({
|
||||
classroomId: classroom.id,
|
||||
playerId,
|
||||
})
|
||||
},
|
||||
[classroom.id, leaveClassroom]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="classroom-tab"
|
||||
@@ -29,51 +47,123 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
|
||||
gap: '24px',
|
||||
})}
|
||||
>
|
||||
{/* 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',
|
||||
})}
|
||||
>
|
||||
{/* Present students section */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3rem',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
🏫
|
||||
Loading classroom...
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
No Students Present
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '400px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
})}
|
||||
>
|
||||
When students join your classroom for practice, they'll appear here. Share your classroom
|
||||
code to get started.
|
||||
</p>
|
||||
) : presentStudents.length > 0 ? (
|
||||
<section data-section="present-students">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'green.500',
|
||||
animation: 'pulse 2s infinite',
|
||||
})}
|
||||
/>
|
||||
<span>Students Present</span>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '24px',
|
||||
height: '24px',
|
||||
padding: '0 8px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{presentStudents.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<ClassroomCodeShare code={classroom.code} />
|
||||
</div>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{presentStudents.map((student) => (
|
||||
<PresentStudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
onRemove={() => handleRemoveStudent(student.id)}
|
||||
isRemoving={
|
||||
leaveClassroom.isPending && leaveClassroom.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',
|
||||
})}
|
||||
>
|
||||
<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 Present
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '400px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
})}
|
||||
>
|
||||
When students join your classroom for practice, they'll appear here. Share your
|
||||
classroom code to get started.
|
||||
</p>
|
||||
|
||||
<ClassroomCodeShare code={classroom.code} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div
|
||||
@@ -108,9 +198,149 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
|
||||
>
|
||||
<li>Share your classroom code with parents</li>
|
||||
<li>Parents enroll their child using the code</li>
|
||||
<li>Students appear here when they start practicing</li>
|
||||
<li>When practicing, students can "enter" the classroom</li>
|
||||
<li>You'll see them appear here in real-time</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
interface PresentStudentCardProps {
|
||||
student: PresenceStudent
|
||||
onRemove: () => void
|
||||
isRemoving: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentStudentCardProps) {
|
||||
const enteredAt = new Date(student.enteredAt)
|
||||
const timeAgo = getTimeAgo(enteredAt)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="present-student-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.800' : 'green.200',
|
||||
boxShadow: isDark ? 'none' : '0 1px 3px rgba(0,0,0,0.05)',
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={`/practice/${student.id}/dashboard`}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
textDecoration: 'none',
|
||||
flex: 1,
|
||||
_hover: { opacity: 0.8 },
|
||||
})}
|
||||
>
|
||||
<div className={css({ position: 'relative' })}>
|
||||
<span
|
||||
className={css({
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.375rem',
|
||||
})}
|
||||
style={{ backgroundColor: student.color }}
|
||||
>
|
||||
{student.emoji}
|
||||
</span>
|
||||
{/* Online indicator */}
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'green.500',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'gray.800' : 'white',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{student.name}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Joined {timeAgo}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
data-action="remove-from-classroom"
|
||||
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',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
},
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isRemoving ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a relative time string (e.g., "2 minutes ago")
|
||||
*/
|
||||
function getTimeAgo(date: Date): string {
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffMinutes === 1) return '1 minute ago'
|
||||
if (diffMinutes < 60) return `${diffMinutes} minutes ago`
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours === 1) return '1 hour ago'
|
||||
if (diffHours < 24) return `${diffHours} hours ago`
|
||||
|
||||
return 'over a day ago'
|
||||
}
|
||||
|
||||
382
apps/web/src/components/classroom/EnterClassroomButton.tsx
Normal file
382
apps/web/src/components/classroom/EnterClassroomButton.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { Classroom } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
useEnrolledClassrooms,
|
||||
useStudentPresence,
|
||||
useEnterClassroom,
|
||||
useLeaveClassroom,
|
||||
} from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface EnterClassroomButtonProps {
|
||||
playerId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EnterClassroomButton - Allows students to enter/leave enrolled classrooms
|
||||
*
|
||||
* Shows:
|
||||
* - Current classroom presence (if in one)
|
||||
* - List of enrolled classrooms to enter
|
||||
* - Enter/leave actions
|
||||
*/
|
||||
export function EnterClassroomButton({ playerId, playerName }: EnterClassroomButtonProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// Fetch enrolled classrooms and current presence
|
||||
const { data: enrolledClassrooms = [], isLoading: loadingClassrooms } =
|
||||
useEnrolledClassrooms(playerId)
|
||||
const { data: currentPresence, isLoading: loadingPresence } = useStudentPresence(playerId)
|
||||
|
||||
// Mutations
|
||||
const enterClassroom = useEnterClassroom()
|
||||
const leaveClassroom = useLeaveClassroom()
|
||||
|
||||
const handleEnter = useCallback(
|
||||
(classroomId: string) => {
|
||||
enterClassroom.mutate(
|
||||
{ classroomId, playerId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[enterClassroom, playerId]
|
||||
)
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
if (!currentPresence) return
|
||||
leaveClassroom.mutate({
|
||||
classroomId: currentPresence.classroomId,
|
||||
playerId,
|
||||
})
|
||||
}, [currentPresence, leaveClassroom, playerId])
|
||||
|
||||
// Don't show while loading or if not enrolled in any classrooms
|
||||
if (loadingClassrooms || enrolledClassrooms.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isInClassroom = !!currentPresence
|
||||
const currentClassroom = currentPresence?.classroom
|
||||
|
||||
return (
|
||||
<div data-component="enter-classroom-button" className={css({ position: 'relative' })}>
|
||||
{/* Main button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-action="toggle-classroom-menu"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: isInClassroom
|
||||
? isDark
|
||||
? 'green.900/30'
|
||||
: 'green.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
color: isInClassroom
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.700',
|
||||
border: '1px solid',
|
||||
borderColor: isInClassroom
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.300'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '10px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isInClassroom
|
||||
? isDark
|
||||
? 'green.900/50'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isInClassroom ? (
|
||||
<>
|
||||
<span
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'green.500',
|
||||
})}
|
||||
/>
|
||||
<span>In {currentClassroom?.name || 'Classroom'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🏫</span>
|
||||
<span>Enter Classroom</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 999,
|
||||
})}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div
|
||||
data-element="classroom-menu"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '8px',
|
||||
minWidth: '280px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{playerName}'s Classrooms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingClassrooms || loadingPresence ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({ maxHeight: '300px', overflowY: 'auto' })}>
|
||||
{/* Current presence */}
|
||||
{currentPresence && currentClassroom && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '12px 16px',
|
||||
backgroundColor: isDark ? 'green.900/20' : 'green.50',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'green.500',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'green.400' : 'green.700',
|
||||
})}
|
||||
>
|
||||
Currently in
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLeave}
|
||||
disabled={leaveClassroom.isPending}
|
||||
data-action="leave-classroom"
|
||||
className={css({
|
||||
padding: '4px 10px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{leaveClassroom.isPending ? 'Leaving...' : 'Leave'}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{currentClassroom.name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrolled classrooms list */}
|
||||
{enrolledClassrooms
|
||||
.filter((c) => c.id !== currentPresence?.classroomId)
|
||||
.map((classroom) => (
|
||||
<ClassroomMenuItem
|
||||
key={classroom.id}
|
||||
classroom={classroom}
|
||||
onEnter={() => handleEnter(classroom.id)}
|
||||
isEntering={
|
||||
enterClassroom.isPending &&
|
||||
enterClassroom.variables?.classroomId === classroom.id
|
||||
}
|
||||
isDisabled={!!currentPresence}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
|
||||
{enrolledClassrooms.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
Not enrolled in any classrooms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ClassroomMenuItemProps {
|
||||
classroom: Classroom
|
||||
onEnter: () => void
|
||||
isEntering: boolean
|
||||
isDisabled: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function ClassroomMenuItem({
|
||||
classroom,
|
||||
onEnter,
|
||||
isEntering,
|
||||
isDisabled,
|
||||
isDark,
|
||||
}: ClassroomMenuItemProps) {
|
||||
return (
|
||||
<div
|
||||
data-element="classroom-menu-item"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.100',
|
||||
_last: { borderBottom: 'none' },
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
fontSize: '0.9375rem',
|
||||
})}
|
||||
>
|
||||
{classroom.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnter}
|
||||
disabled={isEntering || isDisabled}
|
||||
data-action="enter-classroom"
|
||||
className={css({
|
||||
padding: '6px 14px',
|
||||
backgroundColor: isDisabled
|
||||
? isDark
|
||||
? 'gray.700'
|
||||
: 'gray.200'
|
||||
: isDark
|
||||
? 'green.700'
|
||||
: 'green.500',
|
||||
color: isDisabled ? (isDark ? 'gray.500' : 'gray.400') : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
isDisabled || isEntering ? undefined : isDark ? 'green.600' : 'green.600',
|
||||
},
|
||||
_disabled: { opacity: isDisabled ? 0.5 : 1, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isEntering ? 'Entering...' : isDisabled ? 'Leave first' : 'Enter'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
apps/web/src/components/classroom/PendingApprovalsSection.tsx
Normal file
255
apps/web/src/components/classroom/PendingApprovalsSection.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
usePendingApprovalsForParent,
|
||||
useApproveEnrollmentRequestAsParent,
|
||||
useDenyEnrollmentRequestAsParent,
|
||||
type EnrollmentRequestWithRelations,
|
||||
} from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
/**
|
||||
* PendingApprovalsSection - Shows pending enrollment requests for parents
|
||||
*
|
||||
* When a teacher initiates enrollment for a parent's child, the parent
|
||||
* sees it here and can approve or deny.
|
||||
*/
|
||||
export function PendingApprovalsSection() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const { data: requests = [], isLoading } = usePendingApprovalsForParent()
|
||||
const approveRequest = useApproveEnrollmentRequestAsParent()
|
||||
const denyRequest = useDenyEnrollmentRequestAsParent()
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(requestId: string) => {
|
||||
approveRequest.mutate(requestId)
|
||||
},
|
||||
[approveRequest]
|
||||
)
|
||||
|
||||
const handleDeny = useCallback(
|
||||
(requestId: string) => {
|
||||
denyRequest.mutate(requestId)
|
||||
},
|
||||
[denyRequest]
|
||||
)
|
||||
|
||||
// Don't render if no pending requests
|
||||
if (isLoading || requests.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-component="pending-approvals-section"
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
|
||||
borderRadius: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'yellow.700' : 'yellow.200',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'yellow.300' : 'yellow.700',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
})}
|
||||
>
|
||||
<span>📬</span>
|
||||
<span>Enrollment Requests</span>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '24px',
|
||||
height: '24px',
|
||||
padding: '0 8px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDark ? 'yellow.700' : 'yellow.500',
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{requests.length}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'yellow.400' : 'yellow.700',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
A teacher has requested to enroll your child in their classroom.
|
||||
</p>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{requests.map((request) => (
|
||||
<PendingApprovalCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
onApprove={() => handleApprove(request.id)}
|
||||
onDeny={() => handleDeny(request.id)}
|
||||
isApproving={approveRequest.isPending && approveRequest.variables === request.id}
|
||||
isDenying={denyRequest.isPending && denyRequest.variables === request.id}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface PendingApprovalCardProps {
|
||||
request: EnrollmentRequestWithRelations
|
||||
onApprove: () => void
|
||||
onDeny: () => void
|
||||
isApproving: boolean
|
||||
isDenying: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function PendingApprovalCard({
|
||||
request,
|
||||
onApprove,
|
||||
onDeny,
|
||||
isApproving,
|
||||
isDenying,
|
||||
isDark,
|
||||
}: PendingApprovalCardProps) {
|
||||
const player = request.player
|
||||
const classroom = request.classroom
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="pending-approval-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
'@media (min-width: 640px)': {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '14px' })}>
|
||||
<span
|
||||
className={css({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.5rem',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
style={{ backgroundColor: player?.color ?? '#ccc' }}
|
||||
>
|
||||
{player?.emoji ?? '?'}
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1rem',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{player?.name ?? 'Unknown Student'}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Classroom: <strong>{classroom?.name ?? 'Unknown'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
'@media (max-width: 639px)': {
|
||||
width: '100%',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeny}
|
||||
disabled={isDenying || isApproving}
|
||||
data-action="deny-enrollment-parent"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 18px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
'@media (min-width: 640px)': {
|
||||
flex: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isDenying ? 'Denying...' : 'Deny'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
disabled={isApproving || isDenying}
|
||||
data-action="approve-enrollment-parent"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 18px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: { backgroundColor: isDark ? 'green.600' : 'green.600' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
'@media (min-width: 640px)': {
|
||||
flex: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isApproving ? 'Approving...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -402,7 +402,7 @@ function EnrolledStudentCard({
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={`/practice/${student.id}/resume`}
|
||||
href={`/practice/${student.id}/dashboard`}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -3,4 +3,6 @@ export { ClassroomDashboard } from './ClassroomDashboard'
|
||||
export { ClassroomTab } from './ClassroomTab'
|
||||
export { CreateClassroomForm } from './CreateClassroomForm'
|
||||
export { EnrollChildFlow } from './EnrollChildFlow'
|
||||
export { EnterClassroomButton } from './EnterClassroomButton'
|
||||
export { PendingApprovalsSection } from './PendingApprovalsSection'
|
||||
export { StudentManagerTab } from './StudentManagerTab'
|
||||
|
||||
@@ -323,3 +323,275 @@ export function useUnenrollStudent() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parent Enrollment Approval API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch pending enrollment requests for current user as parent
|
||||
*/
|
||||
async function fetchPendingApprovalsForParent(): Promise<EnrollmentRequestWithRelations[]> {
|
||||
const res = await api('enrollment-requests/pending')
|
||||
if (!res.ok) throw new Error('Failed to fetch pending approvals')
|
||||
const data = await res.json()
|
||||
return data.requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve enrollment request as parent
|
||||
*/
|
||||
async function approveRequestAsParent(
|
||||
requestId: string
|
||||
): Promise<{ request: EnrollmentRequest; enrolled: boolean }> {
|
||||
const res = await api(`enrollment-requests/${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 as parent
|
||||
*/
|
||||
async function denyRequestAsParent(requestId: string): Promise<EnrollmentRequest> {
|
||||
const res = await api(`enrollment-requests/${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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parent Enrollment Approval Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get pending enrollment requests for current user as parent
|
||||
*
|
||||
* These are requests initiated by teachers for the user's children,
|
||||
* where parent approval hasn't been given yet.
|
||||
*/
|
||||
export function usePendingApprovalsForParent() {
|
||||
return useQuery({
|
||||
queryKey: classroomKeys.pendingParentApprovals(),
|
||||
queryFn: fetchPendingApprovalsForParent,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve enrollment request as parent
|
||||
*/
|
||||
export function useApproveEnrollmentRequestAsParent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: approveRequestAsParent,
|
||||
onSuccess: (result) => {
|
||||
// Invalidate pending parent approvals
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.pendingParentApprovals(),
|
||||
})
|
||||
// If fully approved, classroom enrollments will be updated too
|
||||
// (but we don't know the classroomId from this response, so broader invalidation)
|
||||
if (result.enrolled) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['classrooms'],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny enrollment request as parent
|
||||
*/
|
||||
export function useDenyEnrollmentRequestAsParent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: denyRequestAsParent,
|
||||
onSuccess: () => {
|
||||
// Invalidate pending parent approvals
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.pendingParentApprovals(),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Classroom Presence API Functions
|
||||
// ============================================================================
|
||||
|
||||
export interface PresenceStudent extends Player {
|
||||
enteredAt: string
|
||||
enteredBy: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch students currently present in a classroom
|
||||
*/
|
||||
async function fetchClassroomPresence(classroomId: string): Promise<PresenceStudent[]> {
|
||||
const res = await api(`classrooms/${classroomId}/presence`)
|
||||
if (!res.ok) throw new Error('Failed to fetch classroom presence')
|
||||
const data = await res.json()
|
||||
return data.students
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a student into a classroom
|
||||
*/
|
||||
async function enterClassroom(params: {
|
||||
classroomId: string
|
||||
playerId: string
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const res = await api(`classrooms/${params.classroomId}/presence`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playerId: params.playerId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
return { success: false, error: data.error || 'Failed to enter classroom' }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a student from a classroom
|
||||
*/
|
||||
async function leaveClassroom(params: { classroomId: string; playerId: string }): Promise<void> {
|
||||
const res = await api(`classrooms/${params.classroomId}/presence/${params.playerId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to leave classroom')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Classroom Presence Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get students currently present in a classroom
|
||||
*/
|
||||
export function useClassroomPresence(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: classroomKeys.presence(classroomId ?? ''),
|
||||
queryFn: () => fetchClassroomPresence(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 15 * 1000, // 15 seconds - presence changes frequently
|
||||
refetchInterval: 30 * 1000, // Poll every 30 seconds for real-time feel
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a student into a classroom
|
||||
*/
|
||||
export function useEnterClassroom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: enterClassroom,
|
||||
onSuccess: (result, { classroomId, playerId }) => {
|
||||
if (result.success) {
|
||||
// Invalidate teacher's view of classroom presence
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.presence(classroomId),
|
||||
})
|
||||
// Invalidate student's view of their own presence
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['players', playerId, 'presence'],
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a student from a classroom
|
||||
*/
|
||||
export function useLeaveClassroom() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: leaveClassroom,
|
||||
onSuccess: (_, { classroomId, playerId }) => {
|
||||
// Invalidate teacher's view of classroom presence
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.presence(classroomId),
|
||||
})
|
||||
// Invalidate student's view of their own presence
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['players', playerId, 'presence'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Student Enrollment API Functions
|
||||
// ============================================================================
|
||||
|
||||
export interface PresenceInfo {
|
||||
playerId: string
|
||||
classroomId: string
|
||||
enteredAt: string
|
||||
enteredBy: string
|
||||
classroom?: Classroom
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch classrooms a student is enrolled in
|
||||
*/
|
||||
async function fetchEnrolledClassrooms(playerId: string): Promise<Classroom[]> {
|
||||
const res = await api(`players/${playerId}/enrolled-classrooms`)
|
||||
if (!res.ok) throw new Error('Failed to fetch enrolled classrooms')
|
||||
const data = await res.json()
|
||||
return data.classrooms
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch student's current classroom presence
|
||||
*/
|
||||
async function fetchStudentPresence(playerId: string): Promise<PresenceInfo | null> {
|
||||
const res = await api(`players/${playerId}/presence`)
|
||||
if (!res.ok) throw new Error('Failed to fetch student presence')
|
||||
const data = await res.json()
|
||||
return data.presence
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Student Enrollment Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get classrooms a student is enrolled in
|
||||
*/
|
||||
export function useEnrolledClassrooms(playerId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['players', playerId, 'enrolled-classrooms'],
|
||||
queryFn: () => fetchEnrolledClassrooms(playerId!),
|
||||
enabled: !!playerId,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get student's current classroom presence
|
||||
*/
|
||||
export function useStudentPresence(playerId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['players', playerId, 'presence'],
|
||||
queryFn: () => fetchStudentPresence(playerId!),
|
||||
enabled: !!playerId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
74
apps/web/src/hooks/useClassroomSocket.ts
Normal file
74
apps/web/src/hooks/useClassroomSocket.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { classroomKeys } from '@/lib/queryKeys'
|
||||
import type { StudentEnteredEvent, StudentLeftEvent } from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Hook for real-time classroom presence updates via WebSocket
|
||||
*
|
||||
* When a student enters or leaves the classroom, this hook receives the event
|
||||
* and automatically invalidates the React Query cache so the UI updates.
|
||||
*
|
||||
* @param classroomId - The classroom to subscribe to
|
||||
* @returns Whether the socket is connected
|
||||
*/
|
||||
export function useClassroomSocket(classroomId: string | undefined): { connected: boolean } {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!classroomId) return
|
||||
|
||||
// Create socket connection
|
||||
const socket = io({
|
||||
path: '/api/socket',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[ClassroomSocket] Connected')
|
||||
setConnected(true)
|
||||
// Join the classroom channel
|
||||
socket.emit('join-classroom', { classroomId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[ClassroomSocket] Disconnected')
|
||||
setConnected(false)
|
||||
})
|
||||
|
||||
// Listen for student entered event
|
||||
socket.on('student-entered', (data: StudentEnteredEvent) => {
|
||||
console.log('[ClassroomSocket] Student entered:', data.playerName)
|
||||
// Invalidate the presence query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.presence(classroomId),
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for student left event
|
||||
socket.on('student-left', (data: StudentLeftEvent) => {
|
||||
console.log('[ClassroomSocket] Student left:', data.playerName)
|
||||
// Invalidate the presence query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.presence(classroomId),
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.emit('leave-classroom', { classroomId })
|
||||
socket.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [classroomId, queryClient])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
65
apps/web/src/hooks/usePlayerPresenceSocket.ts
Normal file
65
apps/web/src/hooks/usePlayerPresenceSocket.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* Hook for real-time player presence updates via WebSocket
|
||||
*
|
||||
* When a student is removed from a classroom (by teacher or parent),
|
||||
* this hook receives the event and invalidates the React Query cache
|
||||
* so the UI updates without requiring a page reload.
|
||||
*
|
||||
* @param playerId - The player to subscribe to
|
||||
* @returns Whether the socket is connected
|
||||
*/
|
||||
export function usePlayerPresenceSocket(playerId: string | undefined): { connected: boolean } {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerId) return
|
||||
|
||||
// Create socket connection
|
||||
const socket = io({
|
||||
path: '/api/socket',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[PlayerPresenceSocket] Connected')
|
||||
setConnected(true)
|
||||
// Join the player channel
|
||||
socket.emit('join-player', { playerId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[PlayerPresenceSocket] Disconnected')
|
||||
setConnected(false)
|
||||
})
|
||||
|
||||
// Listen for presence removed event (when teacher or parent removes student from classroom)
|
||||
socket.on('presence-removed', (data: PresenceRemovedEvent) => {
|
||||
console.log('[PlayerPresenceSocket] Presence removed:', data)
|
||||
// Invalidate the student's presence query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['players', playerId, 'presence'],
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.emit('leave-player', { playerId })
|
||||
socket.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [playerId, queryClient])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
classroomEnrollments,
|
||||
classroomPresence,
|
||||
classrooms,
|
||||
players,
|
||||
type ClassroomPresence,
|
||||
type Classroom,
|
||||
type Player,
|
||||
} from '@/db/schema'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
// ============================================================================
|
||||
// Enter/Leave Classroom
|
||||
@@ -97,6 +99,20 @@ export async function enterClassroom(params: EnterClassroomParams): Promise<Ente
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Emit socket event for real-time updates to teacher
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
// Get player name for the event
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(players.id, playerId),
|
||||
})
|
||||
io.to(`classroom:${classroomId}`).emit('student-entered', {
|
||||
playerId,
|
||||
playerName: player?.name ?? 'Unknown',
|
||||
enteredBy,
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, presence: inserted }
|
||||
}
|
||||
|
||||
@@ -104,18 +120,64 @@ export async function enterClassroom(params: EnterClassroomParams): Promise<Ente
|
||||
* Remove a student from their current classroom
|
||||
*/
|
||||
export async function leaveClassroom(playerId: string): Promise<void> {
|
||||
// Get current presence before deleting (need classroomId for socket event)
|
||||
const presence = await db.query.classroomPresence.findFirst({
|
||||
where: eq(classroomPresence.playerId, playerId),
|
||||
})
|
||||
|
||||
if (!presence) return
|
||||
|
||||
await db.delete(classroomPresence).where(eq(classroomPresence.playerId, playerId))
|
||||
|
||||
// Emit socket event for real-time updates to teacher
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(players.id, playerId),
|
||||
})
|
||||
io.to(`classroom:${presence.classroomId}`).emit('student-left', {
|
||||
playerId,
|
||||
playerName: player?.name ?? 'Unknown',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a student from a specific classroom (if they're in it)
|
||||
*
|
||||
* @param removedBy - Who initiated the removal: 'teacher' or 'self'
|
||||
*/
|
||||
export async function leaveSpecificClassroom(playerId: string, classroomId: string): Promise<void> {
|
||||
await db
|
||||
export async function leaveSpecificClassroom(
|
||||
playerId: string,
|
||||
classroomId: string,
|
||||
removedBy: 'teacher' | 'self' = 'self'
|
||||
): Promise<void> {
|
||||
const deleted = await db
|
||||
.delete(classroomPresence)
|
||||
.where(
|
||||
and(eq(classroomPresence.playerId, playerId), eq(classroomPresence.classroomId, classroomId))
|
||||
)
|
||||
.returning()
|
||||
|
||||
// Only emit if something was actually deleted
|
||||
if (deleted.length > 0) {
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(players.id, playerId),
|
||||
})
|
||||
// Notify the classroom (for teacher's view)
|
||||
io.to(`classroom:${classroomId}`).emit('student-left', {
|
||||
playerId,
|
||||
playerName: player?.name ?? 'Unknown',
|
||||
})
|
||||
// Notify the player (for student's view)
|
||||
io.to(`player:${playerId}`).emit('presence-removed', {
|
||||
classroomId,
|
||||
removedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,15 @@ export interface StudentLeftEvent {
|
||||
playerName: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Player Presence Events (sent to player:${playerId} channel)
|
||||
// ============================================================================
|
||||
|
||||
export interface PresenceRemovedEvent {
|
||||
classroomId: string
|
||||
removedBy: 'teacher' | 'self'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Observation Events (sent to session:${sessionId} channel)
|
||||
// ============================================================================
|
||||
@@ -108,10 +117,13 @@ export interface ClassroomServerToClientEvents {
|
||||
'enrollment-approved': (data: EnrollmentApprovedEvent) => void
|
||||
'enrollment-denied': (data: EnrollmentDeniedEvent) => void
|
||||
|
||||
// Presence events
|
||||
// Presence events (classroom channel)
|
||||
'student-entered': (data: StudentEnteredEvent) => void
|
||||
'student-left': (data: StudentLeftEvent) => void
|
||||
|
||||
// Player presence events (player channel)
|
||||
'presence-removed': (data: PresenceRemovedEvent) => void
|
||||
|
||||
// Session observation events
|
||||
'practice-state': (data: PracticeStateEvent) => void
|
||||
'tutorial-state': (data: TutorialStateEvent) => void
|
||||
@@ -128,6 +140,8 @@ export interface ClassroomClientToServerEvents {
|
||||
// Channel subscriptions
|
||||
'join-classroom': (data: { classroomId: string }) => void
|
||||
'leave-classroom': (data: { classroomId: string }) => void
|
||||
'join-player': (data: { playerId: string }) => void
|
||||
'leave-player': (data: { playerId: string }) => void
|
||||
'observe-session': (data: { sessionId: string; observerId: string }) => void
|
||||
'stop-observing': (data: { sessionId: string }) => void
|
||||
|
||||
|
||||
@@ -43,4 +43,5 @@ export const classroomKeys = {
|
||||
detail: (id: string) => [...classroomKeys.all, 'detail', id] as const,
|
||||
enrollments: (id: string) => [...classroomKeys.all, 'enrollments', id] as const,
|
||||
presence: (id: string) => [...classroomKeys.all, 'presence', id] as const,
|
||||
pendingParentApprovals: () => [...classroomKeys.all, 'pendingParentApprovals'] as const,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import { auth } from '@/auth'
|
||||
import { db, schema } from '@/db'
|
||||
import { GUEST_COOKIE_NAME, verifyGuestToken } from './guest-token'
|
||||
|
||||
/**
|
||||
@@ -65,3 +67,89 @@ export async function getViewerId(): Promise<string> {
|
||||
throw new Error('No valid viewer session found')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a user record from a guestId
|
||||
*
|
||||
* This is the core function for converting a guest session identifier
|
||||
* into a database user record. If no user exists for the guestId,
|
||||
* one is created automatically.
|
||||
*
|
||||
* @param guestId - The guest session identifier
|
||||
* @returns The user record from the database
|
||||
*/
|
||||
async function getOrCreateUserFromGuestId(guestId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, guestId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database user.id for the current viewer
|
||||
*
|
||||
* IMPORTANT: This returns the actual database user.id, NOT the guestId.
|
||||
* Use this when you need to pass a user ID to authorization functions
|
||||
* like canPerformAction(), or any function that expects a database user.id.
|
||||
*
|
||||
* For authenticated users: returns session.user.id directly
|
||||
* For guests: looks up or creates the user record by guestId, returns user.id
|
||||
* For unknown: throws an error
|
||||
*
|
||||
* @throws Error if no valid viewer found
|
||||
*/
|
||||
export async function getDbUserId(): Promise<string> {
|
||||
const viewer = await getViewer()
|
||||
|
||||
switch (viewer.kind) {
|
||||
case 'user':
|
||||
// Authenticated users already have a database user.id in their session
|
||||
return viewer.session.user!.id
|
||||
case 'guest': {
|
||||
// Guests need to look up their user record by guestId
|
||||
const user = await getOrCreateUserFromGuestId(viewer.guestId)
|
||||
return user.id
|
||||
}
|
||||
case 'unknown':
|
||||
throw new Error('No valid viewer session found')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full user record for the current viewer
|
||||
*
|
||||
* This returns the complete database user record, useful when you need
|
||||
* more than just the user.id (e.g., for checking user properties).
|
||||
*
|
||||
* For authenticated users: looks up user by session.user.id
|
||||
* For guests: looks up or creates user by guestId
|
||||
* For unknown: throws an error
|
||||
*
|
||||
* @throws Error if no valid viewer found
|
||||
*/
|
||||
export async function getViewerUser() {
|
||||
const viewer = await getViewer()
|
||||
|
||||
switch (viewer.kind) {
|
||||
case 'user': {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, viewer.session.user!.id),
|
||||
})
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user not found in database')
|
||||
}
|
||||
return user
|
||||
}
|
||||
case 'guest': {
|
||||
return getOrCreateUserFromGuestId(viewer.guestId)
|
||||
}
|
||||
case 'unknown':
|
||||
throw new Error('No valid viewer session found')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,6 +740,26 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
})
|
||||
|
||||
// Player: Join player channel (for students to receive their own presence updates)
|
||||
socket.on('join-player', async ({ playerId }: { playerId: string }) => {
|
||||
try {
|
||||
await socket.join(`player:${playerId}`)
|
||||
console.log(`👤 User joined player channel: ${playerId}`)
|
||||
} catch (error) {
|
||||
console.error('Error joining player channel:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Player: Leave player channel
|
||||
socket.on('leave-player', async ({ playerId }: { playerId: string }) => {
|
||||
try {
|
||||
await socket.leave(`player:${playerId}`)
|
||||
console.log(`👤 User left player channel: ${playerId}`)
|
||||
} catch (error) {
|
||||
console.error('Error leaving player channel:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Session Observation: Start observing a practice session
|
||||
socket.on(
|
||||
'observe-session',
|
||||
|
||||
Reference in New Issue
Block a user