feat(classroom): add unified add-student modal with two-column layout

Consolidates three add-student flows into a single discoverable modal:
- Left column "Add Now": Create student button + family code input
- Right column "Invite Parents": QR code + copy code/link buttons

Also refactors TeacherClassroomCard:
- Moves classroom name from header to first filter segment
- Removes ShareCodePanel from header (now in unified modal)
- Adds classroomName prop to TeacherCompoundChip for label override

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-30 09:03:26 -06:00
parent a5e5788fa9
commit dca696a29f
6 changed files with 889 additions and 148 deletions

View File

@@ -6,6 +6,7 @@ import { useMutation } from '@tanstack/react-query'
import { useToast } from '@/components/common/ToastContext'
import {
AddStudentByFamilyCodeModal,
AddStudentToClassroomModal,
CreateClassroomForm,
PendingApprovalsSection,
SessionObserverModal,
@@ -114,7 +115,10 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
// Track if we're adding to classroom (auto-enroll mode)
const [addToClassroomMode, setAddToClassroomMode] = useState(false)
// Add student modal state (teacher mode - add by family code)
// Unified add student to classroom modal (teacher mode - combines create, share, family code)
const [showUnifiedAddModal, setShowUnifiedAddModal] = useState(false)
// Add student modal state (teacher mode - add by family code) - legacy, kept for direct access
const [showAddByFamilyCode, setShowAddByFamilyCode] = useState(false)
// Session observation state
@@ -302,8 +306,13 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
setShowAddModal(true)
}, [])
// Handle add student to classroom - show create modal with auto-enroll
// Handle add student to classroom - show unified modal with all options
const handleAddStudentToClassroom = useCallback(() => {
setShowUnifiedAddModal(true)
}, [])
// Handle create student from unified modal - opens create modal with auto-enroll
const handleCreateStudentFromUnified = useCallback(() => {
setAddToClassroomMode(true)
setShowAddModal(true)
}, [])
@@ -709,7 +718,19 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
classroomName={addToClassroomMode ? classroom?.name : undefined}
/>
{/* Add Student Modal (Teacher - add by family code) */}
{/* Unified Add Student to Classroom Modal (Teacher mode - combines all options) */}
{classroomId && classroomCode && classroom && (
<AddStudentToClassroomModal
isOpen={showUnifiedAddModal}
onClose={() => setShowUnifiedAddModal(false)}
classroomId={classroomId}
classroomName={classroom.name}
classroomCode={classroomCode}
onCreateStudent={handleCreateStudentFromUnified}
/>
)}
{/* Add Student Modal (Teacher - add by family code) - legacy, kept for direct access */}
{classroomId && (
<AddStudentByFamilyCodeModal
isOpen={showAddByFamilyCode}

View File

@@ -0,0 +1,748 @@
'use client'
import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useState } from 'react'
import { AbacusQRCode } from '@/components/common/AbacusQRCode'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import { useShareCode } from '@/hooks/useShareCode'
import { css } from '../../../styled-system/css'
interface PlayerPreview {
id: string
name: string
emoji: string
color: string
}
interface AddStudentToClassroomModalProps {
isOpen: boolean
onClose: () => void
classroomId: string
classroomName: string
classroomCode: string
/** Called when user wants to create a new student (opens the create student modal) */
onCreateStudent: () => void
}
/**
* Unified modal for adding students to a classroom.
*
* Two columns:
* - Left: "ADD NOW" - Create student button + Enter family code
* - Right: "INVITE PARENTS" - Share classroom code with QR
*
* This consolidates multiple add-student flows into a single discoverable UI.
*/
export function AddStudentToClassroomModal({
isOpen,
onClose,
classroomId,
classroomName,
classroomCode,
onCreateStudent,
}: AddStudentToClassroomModalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Family code input state
const [familyCode, setFamilyCode] = useState('')
const [isSubmittingFamilyCode, setIsSubmittingFamilyCode] = useState(false)
const [familyCodeError, setFamilyCodeError] = useState<string | null>(null)
const [familyCodeSuccess, setFamilyCodeSuccess] = useState(false)
const [enrolledPlayer, setEnrolledPlayer] = useState<PlayerPreview | null>(null)
// Share code hook for the classroom
const shareCode = useShareCode({ type: 'classroom', code: classroomCode })
const handleFamilyCodeSubmit = useCallback(async () => {
if (!familyCode.trim()) {
setFamilyCodeError('Please enter a family code')
return
}
setIsSubmittingFamilyCode(true)
setFamilyCodeError(null)
try {
const response = await fetch(`/api/classrooms/${classroomId}/enroll-by-family-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ familyCode: familyCode.trim() }),
})
const data = await response.json()
if (!data.success) {
setFamilyCodeError(data.error || 'Failed to add student')
return
}
setEnrolledPlayer(data.player)
setFamilyCodeSuccess(true)
} catch (_err) {
setFamilyCodeError('Failed to add student. Please try again.')
} finally {
setIsSubmittingFamilyCode(false)
}
}, [familyCode, classroomId])
const handleClose = useCallback(() => {
// Reset state
setFamilyCode('')
setFamilyCodeError(null)
setFamilyCodeSuccess(false)
setEnrolledPlayer(null)
onClose()
}, [onClose])
const handleCreateStudent = useCallback(() => {
handleClose()
onCreateStudent()
}, [handleClose, onCreateStudent])
const handleAddAnother = useCallback(() => {
setFamilyCode('')
setFamilyCodeError(null)
setFamilyCodeSuccess(false)
setEnrolledPlayer(null)
}, [])
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Portal>
<Dialog.Overlay
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
zIndex: Z_INDEX.MODAL,
})}
/>
<Dialog.Content
data-component="add-student-to-classroom-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
width: 'calc(100% - 2rem)',
maxWidth: { base: '400px', sm: '700px' },
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 20px 50px -12px rgba(0, 0, 0, 0.4)',
zIndex: Z_INDEX.MODAL + 1,
outline: 'none',
})}
>
{/* Header */}
<div
data-element="modal-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<Dialog.Title
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.900',
})}
>
Add Student to {classroomName}
</Dialog.Title>
<Dialog.Close asChild>
<button
type="button"
data-action="close-modal"
className={css({
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: 'transparent',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})}
aria-label="Close"
>
×
</button>
</Dialog.Close>
</div>
{/* Two-column content */}
<div
data-element="modal-content"
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', sm: '1fr 1fr' },
gap: '0',
})}
>
{/* Left column: ADD NOW */}
<div
data-section="add-now"
className={css({
padding: '20px',
borderRight: { base: 'none', sm: '1px solid' },
borderBottom: { base: '1px solid', sm: 'none' },
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Add Now
</h3>
{/* Create Student Button */}
<button
type="button"
onClick={handleCreateStudent}
data-action="create-student"
className={css({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '14px 16px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '10px',
fontSize: '1rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s ease',
marginBottom: '12px',
_hover: {
backgroundColor: isDark ? 'green.600' : 'green.600',
transform: 'translateY(-1px)',
},
_active: {
transform: 'translateY(0)',
},
})}
>
<span className={css({ fontSize: '1.25rem' })}>+</span>
Create Student
</button>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.500' : 'gray.500',
textAlign: 'center',
marginBottom: '20px',
lineHeight: '1.4',
})}
>
Quick setup - student doesn't need an existing account
</p>
{/* Divider */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '20px',
})}
>
<div
className={css({
flex: 1,
height: '1px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
})}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.400',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
or
</span>
<div
className={css({
flex: 1,
height: '1px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
})}
/>
</div>
{/* Family Code Section */}
{familyCodeSuccess && enrolledPlayer ? (
<FamilyCodeSuccess
player={enrolledPlayer}
isDark={isDark}
onAddAnother={handleAddAnother}
onDone={handleClose}
/>
) : (
<FamilyCodeInput
value={familyCode}
onChange={(val) => {
setFamilyCode(val)
setFamilyCodeError(null)
}}
onSubmit={handleFamilyCodeSubmit}
isSubmitting={isSubmittingFamilyCode}
error={familyCodeError}
isDark={isDark}
/>
)}
</div>
{/* Right column: INVITE PARENTS */}
<div
data-section="invite-parents"
className={css({
padding: '20px',
backgroundColor: isDark ? 'gray.750' : 'gray.50',
})}
>
<h3
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '16px',
})}
>
Invite Parents
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '16px',
lineHeight: '1.5',
})}
>
Share this code with parents. They'll enter it to request enrollment, and you'll
approve each request.
</p>
{/* QR Code */}
<div
data-element="qr-code-container"
className={css({
display: 'flex',
justifyContent: 'center',
padding: '16px',
backgroundColor: 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
marginBottom: '16px',
})}
>
<AbacusQRCode value={shareCode.shareUrl} size={160} />
</div>
{/* Code display + copy buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{/* Code button */}
<button
type="button"
onClick={shareCode.copyCode}
data-action="copy-code"
data-status={shareCode.codeCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 16px',
backgroundColor: shareCode.codeCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'purple.900/60'
: 'purple.50',
border: '2px solid',
borderColor: shareCode.codeCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'purple.700'
: 'purple.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.codeCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'purple.800/60'
: 'purple.100',
},
})}
>
<span
className={css({
fontSize: '1.125rem',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing: '0.1em',
color: shareCode.codeCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'purple.300'
: 'purple.700',
})}
>
{shareCode.codeCopied ? ' Copied!' : classroomCode}
</span>
{!shareCode.codeCopied && (
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'purple.400' : 'purple.500',
})}
>
Copy
</span>
)}
</button>
{/* Link button */}
<button
type="button"
onClick={shareCode.copyLink}
data-action="copy-link"
data-status={shareCode.linkCopied ? 'copied' : 'idle'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px 16px',
backgroundColor: shareCode.linkCopied
? isDark
? 'green.900/60'
: 'green.50'
: isDark
? 'blue.900/60'
: 'blue.50',
border: '1px solid',
borderColor: shareCode.linkCopied
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'blue.700'
: 'blue.300',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: shareCode.linkCopied
? isDark
? 'green.800/60'
: 'green.100'
: isDark
? 'blue.800/60'
: 'blue.100',
},
})}
>
<span
className={css({
fontSize: '0.875rem',
color: shareCode.linkCopied
? isDark
? 'green.300'
: 'green.700'
: isDark
? 'blue.300'
: 'blue.700',
})}
>
{shareCode.linkCopied ? ' Link copied!' : '🔗 Copy link to share'}
</span>
</button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
// --- Sub-components ---
interface FamilyCodeInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
isSubmitting: boolean
error: string | null
isDark: boolean
}
function FamilyCodeInput({
value,
onChange,
onSubmit,
isSubmitting,
error,
isDark,
}: FamilyCodeInputProps) {
return (
<div data-element="family-code-section">
<label
htmlFor="family-code-input"
className={css({
display: 'block',
fontSize: '0.875rem',
fontWeight: '600',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '8px',
})}
>
Have a parent's family code?
</label>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '12px',
lineHeight: '1.4',
})}
>
Enter their code to request enrollment. The parent will need to approve.
</p>
<div
className={css({
display: 'flex',
gap: '8px',
marginBottom: error ? '8px' : '0',
})}
>
<input
id="family-code-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
onKeyDown={(e) => {
if (e.key === 'Enter' && value.trim() && !isSubmitting) {
onSubmit()
}
}}
placeholder="e.g., ABCD-1234"
data-element="family-code-input"
className={css({
flex: 1,
padding: '10px 12px',
fontSize: '1rem',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '0.08em',
backgroundColor: isDark ? 'gray.700' : 'white',
border: '2px solid',
borderColor: error
? isDark
? 'red.500'
: 'red.400'
: isDark
? 'gray.600'
: 'gray.300',
borderRadius: '8px',
color: isDark ? 'white' : 'gray.900',
outline: 'none',
_focus: {
borderColor: 'blue.500',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
},
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
},
})}
/>
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
data-action="submit-family-code"
className={css({
padding: '10px 16px',
backgroundColor: isDark ? 'blue.700' : 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
_hover: {
backgroundColor: isDark ? 'blue.600' : 'blue.600',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{isSubmitting ? 'Adding...' : 'Add'}
</button>
</div>
{error && (
<div
data-element="error-message"
className={css({
padding: '10px 12px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
border: '1px solid',
borderColor: isDark ? 'red.700' : 'red.200',
borderRadius: '8px',
color: isDark ? 'red.300' : 'red.700',
fontSize: '0.8125rem',
})}
>
{error}
</div>
)}
</div>
)
}
interface FamilyCodeSuccessProps {
player: PlayerPreview
isDark: boolean
onAddAnother: () => void
onDone: () => void
}
function FamilyCodeSuccess({ player, isDark, onAddAnother, onDone }: FamilyCodeSuccessProps) {
return (
<div data-element="family-code-success" className={css({ textAlign: 'center' })}>
<div
className={css({
width: '56px',
height: '56px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.75rem',
margin: '0 auto 12px',
})}
style={{ backgroundColor: player.color }}
>
{player.emoji}
</div>
<h4
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.900',
marginBottom: '4px',
})}
>
Request Sent!
</h4>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '16px',
lineHeight: '1.4',
})}
>
<strong>{player.name}</strong> will be added once their parent approves.
</p>
<div className={css({ display: 'flex', gap: '8px' })}>
<button
type="button"
onClick={onAddAnother}
data-action="add-another"
className={css({
flex: 1,
padding: '10px 12px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: '500',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
},
})}
>
Add Another
</button>
<button
type="button"
onClick={onDone}
data-action="done"
className={css({
flex: 1,
padding: '10px 12px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: '500',
cursor: 'pointer',
_hover: {
backgroundColor: isDark ? 'green.600' : 'green.600',
},
})}
>
Done
</button>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
export { AddStudentToClassroomModal } from './AddStudentToClassroomModal'
export { ClassroomCodeShare } from './ClassroomCodeShare'
export { CreateClassroomForm } from './CreateClassroomForm'
export { EnrollChildFlow } from './EnrollChildFlow'

View File

@@ -376,20 +376,20 @@ function CompactShareChip({
css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
bg: isDark ? 'gray.700' : 'gray.100',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '6px',
fontSize: '12px',
gap: '3px',
padding: '2px 6px',
bg: 'transparent',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'monospace',
color: isDark ? 'gray.300' : 'gray.600',
fontWeight: '500',
color: isDark ? 'gray.400' : 'gray.500',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
bg: isDark ? 'gray.600' : 'gray.200',
borderColor: isDark ? 'gray.500' : 'gray.400',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.600',
},
_active: {
transform: 'scale(0.98)',
@@ -397,7 +397,7 @@ function CompactShareChip({
}) + (className ? ` ${className}` : '')
}
>
<span>📋</span>
<span className={css({ fontSize: '10px' })}>📋</span>
<span>{code}</span>
</button>
</Popover.Trigger>

View File

@@ -684,27 +684,16 @@ export function TeacherClassroomCard({
}: TeacherClassroomCardProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const shareCode = useShareCode({ type: 'classroom', code: classroom.code })
const updateClassroom = useUpdateClassroom()
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState(classroom.name)
const nameInputRef = useRef<HTMLInputElement>(null)
// Reset name value when classroom changes or popover opens
// Reset name value when classroom changes or settings popover opens
useEffect(() => {
setNameValue(classroom.name)
setEditingName(false)
}, [classroom.name, isSettingsOpen])
// Focus input when editing starts
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus()
nameInputRef.current.select()
}
}, [editingName])
const handleNameSave = useCallback(() => {
const trimmedName = nameValue.trim()
if (trimmedName && trimmedName !== classroom.name) {
@@ -713,7 +702,6 @@ export function TeacherClassroomCard({
name: trimmedName,
})
}
setEditingName(false)
}, [classroom.id, classroom.name, nameValue, updateClassroom])
const handleExpiryChange = useCallback(
@@ -734,7 +722,7 @@ export function TeacherClassroomCard({
className={css({
display: 'flex',
flexDirection: 'column',
borderRadius: '12px',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: isDark ? 'gray.750' : 'gray.50',
@@ -742,102 +730,30 @@ export function TeacherClassroomCard({
flexShrink: 0,
})}
>
{/* Header row: Classroom name + actions */}
{/* Header row: Action buttons only - classroom name is now in first segment */}
<div
data-element="classroom-card-header"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '8px 12px',
justifyContent: 'flex-end',
gap: '4px',
padding: '4px 8px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Classroom name - editable */}
<div
data-element="classroom-name"
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
flex: 1,
})}
>
<span className={css({ fontSize: '14px', flexShrink: 0 })}>📚</span>
{editingName ? (
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleNameSave()
} else if (e.key === 'Escape') {
setNameValue(classroom.name)
setEditingName(false)
}
}}
onBlur={handleNameSave}
disabled={updateClassroom.isPending}
className={css({
flex: 1,
minWidth: '100px',
padding: '2px 6px',
fontSize: '14px',
fontWeight: '600',
borderRadius: '4px',
border: '1px solid',
borderColor: 'blue.500',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
outline: 'none',
})}
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className={css({
fontSize: '14px',
fontWeight: '600',
color: isDark ? 'gray.100' : 'gray.800',
cursor: 'pointer',
padding: '2px 4px',
borderRadius: '4px',
background: 'transparent',
border: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
_hover: {
bg: isDark ? 'gray.600' : 'gray.200',
},
})}
title="Click to edit classroom name"
>
{classroom.name}
</button>
)}
</div>
{/* Action buttons */}
{/* Action buttons - minimal icons */}
<div
data-element="classroom-actions"
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
gap: '2px',
flexShrink: 0,
})}
>
{/* Share code chip */}
<ShareCodePanel shareCode={shareCode} compact showRegenerate={false} />
{/* Add student button - auto-enrolls in classroom */}
{/* Add student button - opens unified modal with share code, create, family code */}
{onAddStudentToClassroom && (
<button
type="button"
@@ -848,19 +764,17 @@ export function TeacherClassroomCard({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.300',
backgroundColor: isDark ? 'green.900' : 'green.50',
width: '22px',
height: '22px',
borderRadius: '4px',
border: 'none',
backgroundColor: 'transparent',
color: isDark ? 'green.400' : 'green.600',
fontSize: '16px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'green.800' : 'green.100',
borderColor: isDark ? 'green.600' : 'green.400',
backgroundColor: isDark ? 'green.900' : 'green.100',
},
})}
>
@@ -868,7 +782,7 @@ export function TeacherClassroomCard({
</button>
)}
{/* Settings button with popover */}
{/* Settings button with popover - icon only */}
<Popover.Root open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
<Popover.Trigger asChild>
<button
@@ -878,19 +792,17 @@ export function TeacherClassroomCard({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
width: '22px',
height: '22px',
borderRadius: '4px',
border: 'none',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: '14px',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.100',
borderColor: isDark ? 'gray.500' : 'gray.400',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
},
})}
aria-label="Classroom settings"
@@ -928,6 +840,52 @@ export function TeacherClassroomCard({
Classroom Settings
</h3>
{/* Classroom name setting */}
<div data-setting="classroom-name" className={css({ marginBottom: '12px' })}>
<label
className={css({
display: 'block',
fontSize: '12px',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '4px',
})}
>
Classroom name
</label>
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleNameSave()
} else if (e.key === 'Escape') {
setNameValue(classroom.name)
}
}}
onBlur={handleNameSave}
disabled={updateClassroom.isPending}
className={css({
width: '100%',
padding: '6px 8px',
fontSize: '13px',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'gray.100' : 'gray.800',
cursor: updateClassroom.isPending ? 'wait' : 'text',
opacity: updateClassroom.isPending ? 0.7 : 1,
_focus: {
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '1px',
},
})}
/>
</div>
{/* Entry prompt expiry setting */}
<div data-setting="entry-prompt-expiry">
<label
@@ -994,21 +952,15 @@ export function TeacherClassroomCard({
</div>
</div>
{/* Filter row: Embedded compound chip */}
<div
data-element="classroom-card-filters"
className={css({
padding: '6px 8px',
})}
>
<TeacherCompoundChip
currentView={currentView}
onViewChange={onViewChange}
viewCounts={viewCounts}
availableViews={availableViews}
embedded
/>
</div>
{/* Filter row: Embedded compound chip - flush with card edges */}
<TeacherCompoundChip
currentView={currentView}
onViewChange={onViewChange}
viewCounts={viewCounts}
availableViews={availableViews}
embedded
classroomName={classroom.name}
/>
</div>
)
}

View File

@@ -533,6 +533,8 @@ export interface TeacherCompoundChipProps {
isDark?: boolean
/** When true, removes outer border/radius (for embedding in a card) */
embedded?: boolean
/** Optional classroom name to display instead of "Enrolled" in first segment */
classroomName?: string
}
/**
@@ -550,6 +552,7 @@ export function TeacherCompoundChip({
availableViews,
isDark: isDarkProp,
embedded = false,
classroomName,
}: TeacherCompoundChipProps) {
const { resolvedTheme } = useTheme()
const isDark = isDarkProp ?? resolvedTheme === 'dark'
@@ -573,11 +576,14 @@ export function TeacherCompoundChip({
className={css({
display: 'flex',
alignItems: 'stretch',
borderRadius: '16px',
border: '1px solid',
// When embedded, no border/radius - blends into parent card
borderRadius: embedded ? '0' : '16px',
border: embedded ? 'none' : '1px solid',
overflow: 'hidden',
flexShrink: 0,
transition: 'all 0.15s ease',
// When embedded, fill width
width: embedded ? '100%' : 'auto',
borderColor: isAnyActive
? isDark
? 'blue.600'
@@ -587,7 +593,7 @@ export function TeacherCompoundChip({
: 'gray.300',
})}
>
{/* Enrolled segment */}
{/* Enrolled segment - shows classroom name if provided */}
<ChipSegment
config={enrolledConfig}
isActive={isEnrolledActive}
@@ -597,6 +603,8 @@ export function TeacherCompoundChip({
position="first"
isCompoundActive={isAnyActive}
colorScheme="blue"
embedded={embedded}
labelOverride={classroomName}
/>
{/* In Classroom segment */}
@@ -609,6 +617,7 @@ export function TeacherCompoundChip({
position={hasActiveSegment ? 'middle' : 'last'}
isCompoundActive={isAnyActive}
colorScheme="blue"
embedded={embedded}
/>
{/* Active segment - only show if there are active sessions */}
@@ -622,6 +631,7 @@ export function TeacherCompoundChip({
position="last"
isCompoundActive={isAnyActive}
colorScheme="green"
embedded={embedded}
/>
)}
</div>
@@ -637,6 +647,10 @@ interface ChipSegmentProps {
position: 'first' | 'middle' | 'last'
isCompoundActive: boolean
colorScheme: 'blue' | 'green'
/** When embedded in a card, segments flex evenly */
embedded?: boolean
/** Optional label override (e.g., classroom name instead of "Enrolled") */
labelOverride?: string
}
function ChipSegment({
@@ -648,6 +662,8 @@ function ChipSegment({
position,
isCompoundActive,
colorScheme,
embedded = false,
labelOverride,
}: ChipSegmentProps) {
const isLast = position === 'last'
@@ -661,14 +677,17 @@ function ChipSegment({
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
justifyContent: embedded ? 'center' : 'flex-start',
gap: embedded ? '4px' : '6px',
padding: embedded ? '4px 8px' : '6px 12px',
cursor: 'pointer',
fontSize: '13px',
fontSize: embedded ? '11px' : '13px',
fontWeight: 'medium',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
border: 'none',
// When embedded, segments share space equally
flex: embedded ? 1 : 'none',
borderRight: isLast ? 'none' : '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: isActive
@@ -708,7 +727,7 @@ function ChipSegment({
})}
>
<span>{config.icon}</span>
<span>{config.shortLabel ?? config.label}</span>
<span>{labelOverride ?? config.shortLabel ?? config.label}</span>
{count !== undefined && (
<span
data-element="segment-count"