feat(practice): add mini start practice banner to QuickLook modal
- Create MiniStartPracticeBanner component showing session mode or active session status - Shows "Start" for idle students (navigates to dashboard with startPractice=true) - Shows "Resume" for practicing students (parent view) - Shows "Watch" for practicing students (teacher view, opens SessionObserverModal) - Add session observer integration for teachers to watch active sessions - Mode-specific styling: amber for remediation, green for progression, blue for maintenance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
280
apps/web/src/components/practice/MiniStartPracticeBanner.tsx
Normal file
280
apps/web/src/components/practice/MiniStartPracticeBanner.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { SessionMode } from '@/lib/curriculum/session-mode'
|
||||
import type { StudentActivity } from '@/types/student'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface MiniStartPracticeBannerProps {
|
||||
/** Session mode data (null if not loaded yet) */
|
||||
sessionMode: SessionMode | null
|
||||
/** Current activity status */
|
||||
activity: StudentActivity | null
|
||||
/** Whether the viewer is a teacher (affects "Watch" vs "Resume" for active sessions) */
|
||||
isTeacher?: boolean
|
||||
/** Called when "Start" is clicked - should open StartPracticeModal */
|
||||
onStartPractice: () => void
|
||||
/** Called when "Resume" is clicked - navigates to active session */
|
||||
onResumePractice: () => void
|
||||
/** Called when "Watch" is clicked - opens session observer */
|
||||
onWatchSession: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mode-specific configurations
|
||||
// ============================================================================
|
||||
|
||||
interface ModeConfig {
|
||||
icon: string
|
||||
label: string
|
||||
sublabel?: string
|
||||
buttonLabel: string
|
||||
bgGradient: { light: string; dark: string }
|
||||
borderColor: { light: string; dark: string }
|
||||
textColor: { light: string; dark: string }
|
||||
buttonGradient: string
|
||||
}
|
||||
|
||||
function getIdleModeConfig(sessionMode: SessionMode): ModeConfig {
|
||||
switch (sessionMode.type) {
|
||||
case 'remediation': {
|
||||
const hasBlockedPromotion = !!sessionMode.blockedPromotion
|
||||
const weakCount = sessionMode.weakSkills.length
|
||||
return {
|
||||
icon: hasBlockedPromotion ? '🔒' : '💪',
|
||||
label: hasBlockedPromotion ? 'Almost there!' : 'Build strength',
|
||||
sublabel: `${weakCount} skill${weakCount > 1 ? 's' : ''} to strengthen`,
|
||||
buttonLabel: 'Start',
|
||||
bgGradient: {
|
||||
light: 'linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.05) 100%)',
|
||||
dark: 'linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.08) 100%)',
|
||||
},
|
||||
borderColor: { light: '#fbbf24', dark: '#d97706' },
|
||||
textColor: { light: '#b45309', dark: '#fcd34d' },
|
||||
buttonGradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
}
|
||||
}
|
||||
case 'progression': {
|
||||
const tutorialRequired = sessionMode.tutorialRequired
|
||||
return {
|
||||
icon: '🌟',
|
||||
label: tutorialRequired ? 'New skill unlocked!' : 'Ready to learn',
|
||||
sublabel: sessionMode.nextSkill.displayName,
|
||||
buttonLabel: tutorialRequired ? 'Learn' : 'Start',
|
||||
bgGradient: {
|
||||
light: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)',
|
||||
dark: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)',
|
||||
},
|
||||
borderColor: { light: '#22c55e', dark: '#16a34a' },
|
||||
textColor: { light: '#166534', dark: '#86efac' },
|
||||
buttonGradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
}
|
||||
}
|
||||
case 'maintenance': {
|
||||
return {
|
||||
icon: '✨',
|
||||
label: 'All skills strong!',
|
||||
sublabel: `${sessionMode.skillCount} skills mastered`,
|
||||
buttonLabel: 'Start',
|
||||
bgGradient: {
|
||||
light: 'linear-gradient(135deg, rgba(59, 130, 246, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
|
||||
dark: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(139, 92, 246, 0.08) 100%)',
|
||||
},
|
||||
borderColor: { light: '#3b82f6', dark: '#2563eb' },
|
||||
textColor: { light: '#1d4ed8', dark: '#93c5fd' },
|
||||
buttonGradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveSessionConfig(activity: StudentActivity, isTeacher: boolean): ModeConfig {
|
||||
const progress = activity.sessionProgress
|
||||
const progressText = progress ? `${progress.current}/${progress.total} problems` : 'In progress'
|
||||
|
||||
if (isTeacher) {
|
||||
// Teacher sees "Watch" option
|
||||
return {
|
||||
icon: '👁',
|
||||
label: 'Practicing now',
|
||||
sublabel: progressText,
|
||||
buttonLabel: 'Watch',
|
||||
bgGradient: {
|
||||
light: 'linear-gradient(135deg, rgba(139, 92, 246, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)',
|
||||
dark: 'linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)',
|
||||
},
|
||||
borderColor: { light: '#8b5cf6', dark: '#7c3aed' },
|
||||
textColor: { light: '#6d28d9', dark: '#c4b5fd' },
|
||||
buttonGradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
}
|
||||
} else {
|
||||
// Parent/student sees "Resume" option
|
||||
return {
|
||||
icon: '▶️',
|
||||
label: 'Session in progress',
|
||||
sublabel: progressText,
|
||||
buttonLabel: 'Resume',
|
||||
bgGradient: {
|
||||
light: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(16, 185, 129, 0.04) 100%)',
|
||||
dark: 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 185, 129, 0.08) 100%)',
|
||||
},
|
||||
borderColor: { light: '#22c55e', dark: '#16a34a' },
|
||||
textColor: { light: '#166534', dark: '#86efac' },
|
||||
buttonGradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mini Start Practice Banner - compact version for QuickLook modal
|
||||
*
|
||||
* Shows different content based on state:
|
||||
* - Idle + session mode: Shows session mode info with "Start" CTA
|
||||
* - Active session (teacher): Shows "Watch" to observe
|
||||
* - Active session (parent): Shows "Resume" to continue
|
||||
*
|
||||
* Designed to fit above the Overview/Notes tabs.
|
||||
*/
|
||||
export function MiniStartPracticeBanner({
|
||||
sessionMode,
|
||||
activity,
|
||||
isTeacher = false,
|
||||
onStartPractice,
|
||||
onResumePractice,
|
||||
onWatchSession,
|
||||
}: MiniStartPracticeBannerProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const isPracticing = activity?.status === 'practicing'
|
||||
|
||||
// Determine config and handler based on state
|
||||
let config: ModeConfig
|
||||
let handleClick: () => void
|
||||
|
||||
if (isPracticing) {
|
||||
config = getActiveSessionConfig(activity, isTeacher)
|
||||
handleClick = isTeacher ? onWatchSession : onResumePractice
|
||||
} else if (sessionMode) {
|
||||
config = getIdleModeConfig(sessionMode)
|
||||
handleClick = onStartPractice
|
||||
} else {
|
||||
// No session mode data yet - don't render
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="mini-start-practice-banner"
|
||||
data-mode={isPracticing ? 'active' : sessionMode?.type}
|
||||
data-variant={isPracticing ? (isTeacher ? 'watch' : 'resume') : 'start'}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
margin: '8px 12px 0',
|
||||
border: '1px solid',
|
||||
})}
|
||||
style={{
|
||||
background: isDark ? config.bgGradient.dark : config.bgGradient.light,
|
||||
borderColor: isDark ? config.borderColor.dark : config.borderColor.light,
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
|
||||
{/* Text content */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
style={{ color: isDark ? config.textColor.dark : config.textColor.light }}
|
||||
>
|
||||
{config.label}
|
||||
</div>
|
||||
{config.sublabel && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginTop: '1px',
|
||||
})}
|
||||
style={{ color: isDark ? '#a1a1aa' : '#6b7280' }}
|
||||
>
|
||||
{config.sublabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action={isPracticing ? (isTeacher ? 'watch-session' : 'resume-practice') : 'start-practice'}
|
||||
onClick={handleClick}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
filter: 'brightness(1.05)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
background: config.buttonGradient,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<span>{config.buttonLabel}</span>
|
||||
<span className={css({ fontSize: '0.75rem' })}>→</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MiniStartPracticeBanner
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { EnrollChildModal } from '@/components/classroom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { EnrollChildModal, SessionObserverModal } from '@/components/classroom'
|
||||
import { FamilyCodeDisplay } from '@/components/family'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { usePageTransition } from '@/contexts/PageTransitionContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useMyClassroom, type ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
import { usePlayerCurriculumQuery } from '@/hooks/usePlayerCurriculum'
|
||||
import { useSessionMode } from '@/hooks/useSessionMode'
|
||||
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
|
||||
import { useUpdatePlayer } from '@/hooks/useUserPlayers'
|
||||
import type { StudentActivity, StudentRelationship, UnifiedStudent } from '@/types/student'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { MiniStartPracticeBanner } from './MiniStartPracticeBanner'
|
||||
import { ACTION_DEFINITIONS } from './studentActions'
|
||||
|
||||
// ============================================================================
|
||||
@@ -112,6 +116,7 @@ function buildStudentActionData(student: StudentProp): StudentActionData {
|
||||
* - Zoom animation from source tile
|
||||
*/
|
||||
export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModalProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { startTransition } = usePageTransition()
|
||||
@@ -123,6 +128,9 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isHiddenForTransition, setIsHiddenForTransition] = useState(false)
|
||||
|
||||
// State for session observer modal
|
||||
const [showSessionObserver, setShowSessionObserver] = useState(false)
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -132,8 +140,30 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
|
||||
// ========== Additional data for Overview tab ==========
|
||||
const { data: curriculumData } = usePlayerCurriculumQuery(student.id)
|
||||
const { data: sessionMode } = useSessionMode(student.id)
|
||||
const { data: classroom } = useMyClassroom()
|
||||
const isTeacher = !!classroom
|
||||
const updatePlayer = useUpdatePlayer() // For notes only
|
||||
|
||||
// Build ActiveSessionInfo for session observer (when student is practicing)
|
||||
const activeSessionInfo = useMemo<ActiveSessionInfo | null>(() => {
|
||||
const activityData = student.activity ?? null
|
||||
if (activityData?.status !== 'practicing' || !activityData.sessionId) {
|
||||
return null
|
||||
}
|
||||
const progress = activityData.sessionProgress
|
||||
return {
|
||||
sessionId: activityData.sessionId,
|
||||
playerId: student.id,
|
||||
startedAt: new Date().toISOString(), // Best guess - not stored in activity
|
||||
currentPartIndex: 0,
|
||||
currentSlotIndex: progress?.current ?? 0,
|
||||
totalParts: 1,
|
||||
totalProblems: progress?.total ?? 0,
|
||||
completedProblems: progress?.current ?? 0,
|
||||
}
|
||||
}, [student.activity, student.id])
|
||||
|
||||
// ========== Derived data ==========
|
||||
const relationship: StudentRelationship | null = student.relationship ?? null
|
||||
const activity: StudentActivity | null = student.activity ?? null
|
||||
@@ -157,6 +187,30 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
// Default tab based on content
|
||||
const defaultTab: TabId = hasOverviewContent ? 'overview' : 'notes'
|
||||
|
||||
// ========== Mini Banner Handlers ==========
|
||||
|
||||
const handleBannerStartPractice = useCallback(() => {
|
||||
// Navigate to dashboard with startPractice param to open the modal there
|
||||
// This ensures all required data (avgSecondsPerProblem, problemHistory, etc.) is available
|
||||
onClose()
|
||||
router.push(`/practice/${student.id}/dashboard?startPractice=true`)
|
||||
}, [onClose, router, student.id])
|
||||
|
||||
const handleBannerResumePractice = useCallback(() => {
|
||||
// Navigate to the active practice session
|
||||
onClose()
|
||||
router.push(`/practice/${student.id}`)
|
||||
}, [onClose, router, student.id])
|
||||
|
||||
const handleBannerWatchSession = useCallback(() => {
|
||||
// Open session observer modal
|
||||
setShowSessionObserver(true)
|
||||
}, [])
|
||||
|
||||
const handleSessionObserverClose = useCallback(() => {
|
||||
setShowSessionObserver(false)
|
||||
}, [])
|
||||
|
||||
// ========== Effects ==========
|
||||
|
||||
// Reset state when modal opens/closes or student changes
|
||||
@@ -166,6 +220,7 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
setIsEditing(false)
|
||||
setActiveTab(defaultTab)
|
||||
setIsHiddenForTransition(false)
|
||||
setShowSessionObserver(false)
|
||||
}
|
||||
}, [isOpen, student.id, student.notes, defaultTab])
|
||||
|
||||
@@ -559,6 +614,16 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini Start Practice Banner - shows session mode or active session */}
|
||||
<MiniStartPracticeBanner
|
||||
sessionMode={sessionMode ?? null}
|
||||
activity={activity}
|
||||
isTeacher={isTeacher}
|
||||
onStartPractice={handleBannerStartPractice}
|
||||
onResumePractice={handleBannerResumePractice}
|
||||
onWatchSession={handleBannerWatchSession}
|
||||
/>
|
||||
|
||||
{/* Tab bar - only show if Overview has content */}
|
||||
{hasOverviewContent && (
|
||||
<div
|
||||
@@ -640,6 +705,21 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa
|
||||
playerId={student.id}
|
||||
playerName={student.name}
|
||||
/>
|
||||
|
||||
{/* Session Observer Modal - for teachers watching student practice */}
|
||||
{showSessionObserver && activeSessionInfo && classroom && (
|
||||
<SessionObserverModal
|
||||
isOpen={showSessionObserver}
|
||||
onClose={handleSessionObserverClose}
|
||||
session={activeSessionInfo}
|
||||
student={{
|
||||
name: student.name,
|
||||
emoji: student.emoji,
|
||||
color: student.color,
|
||||
}}
|
||||
observerId={classroom.teacherId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
useHasPhysicalKeyboard,
|
||||
useIsTouchDevice,
|
||||
} from './hooks/useDeviceDetection'
|
||||
export { MiniStartPracticeBanner } from './MiniStartPracticeBanner'
|
||||
export { NotesModal } from './NotesModal'
|
||||
// StudentQuickLook is an alias for NotesModal (which was enhanced to serve as the QuickLook modal)
|
||||
export { NotesModal as StudentQuickLook } from './NotesModal'
|
||||
|
||||
Reference in New Issue
Block a user