Two-phase extraction: - Detects end-of-document answer key format by scanning last 40 pages for "Preferred Response:" (PREP 2013, 2014 etc use this vs PREP 2012 inline "Correct Answer:") - Phase 1: Extract questions with item_number field, allow null correct_answer - Phase 2: Extract answer key (item_number → letter) from last 40% of document - Phase 3: Match questions to answers by item number, resolve letter → full option text - Unmatched questions go to skipped list with reason shown in Jobs page - Standard inline format (PREP 2012) unchanged Updated extraction prompts: - item_number field added to all extractions for cross-referencing - Image content rule: "Item CXXXB" figure references must NOT be treated as new questions - Recognises both "Correct Answer: X" and "Preferred Response: X" - ANSWER_KEY_PROMPT: dedicated prompt for extracting answer key tables Quiz navigation scroll: - Clicking Next, Previous, or question number now scrolls the question card into view (smooth scroll to start of question-card div) Code: extract_questions_no_answers(), extract_answer_key(), _call_model() added to ai_service.py Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
444 lines
20 KiB
JavaScript
444 lines
20 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import api from '../api/client'
|
||
|
||
function TTSButton({ text, voice, onActiveChange }) {
|
||
const [state, setState] = useState('idle') // idle | loading | playing
|
||
const audioRef = useRef(null)
|
||
|
||
// Stop audio when question changes (component unmounts)
|
||
useEffect(() => {
|
||
return () => {
|
||
audioRef.current?.pause()
|
||
onActiveChange?.(false)
|
||
}
|
||
}, [])
|
||
|
||
const setStateAndNotify = (s) => {
|
||
setState(s)
|
||
onActiveChange?.(s !== 'idle')
|
||
}
|
||
|
||
const speak = async () => {
|
||
if (state === 'playing') {
|
||
audioRef.current?.pause()
|
||
setStateAndNotify('idle')
|
||
return
|
||
}
|
||
if (state === 'loading') return
|
||
try {
|
||
setStateAndNotify('loading')
|
||
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||
const url = URL.createObjectURL(res.data)
|
||
const audio = new Audio(url)
|
||
audioRef.current = audio
|
||
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
|
||
audio.onerror = () => setStateAndNotify('idle')
|
||
audio.play()
|
||
setStateAndNotify('playing')
|
||
} catch { setStateAndNotify('idle') }
|
||
}
|
||
|
||
const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : '🔊 Listen'
|
||
const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff'
|
||
const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3'
|
||
|
||
return (
|
||
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
|
||
background: bg, color, border: 'none', borderRadius: '6px',
|
||
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
|
||
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
|
||
}}>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function TimerDisplay({ seconds, total }) {
|
||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||
const mins = Math.floor(seconds / 60), secs = seconds % 60
|
||
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
|
||
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
|
||
</div>
|
||
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ModeSelectScreen({ quiz, voices, onStart }) {
|
||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||
const skipped = quiz.skipped_questions ? JSON.parse(quiz.skipped_questions) : []
|
||
|
||
return (
|
||
<div style={{ maxWidth: 520, margin: '40px auto' }}>
|
||
<div className="card" style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
|
||
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
|
||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
|
||
{quiz.questions_count} questions
|
||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
||
</p>
|
||
|
||
|
||
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
|
||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
|
||
{[
|
||
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
|
||
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Timed, answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
|
||
].map(({ mode, icon, label, desc, color, bg }) => (
|
||
<div key={mode} onClick={() => onStart(mode, selectedVoice)}
|
||
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
|
||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
|
||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||
>
|
||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
|
||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
|
||
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{voices.length > 0 && (
|
||
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: 16 }}>
|
||
<label style={{ fontSize: '0.85rem', color: '#64748b', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
|
||
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
|
||
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
|
||
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function QuizPage() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
const { user } = useAuth()
|
||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||
const [quiz, setQuiz] = useState(null)
|
||
const [voices, setVoices] = useState([])
|
||
const [selectedVoice, setSelectedVoice] = useState('')
|
||
const [ttsActive, setTtsActive] = useState(false)
|
||
const [quizMode, setQuizMode] = useState(null)
|
||
const [answers, setAnswers] = useState({})
|
||
const [currentIdx, setCurrentIdx] = useState(0)
|
||
const [loading, setLoading] = useState(true)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [attemptId, setAttemptId] = useState(null)
|
||
const [timeLeft, setTimeLeft] = useState(null)
|
||
const [totalTime, setTotalTime] = useState(null)
|
||
const [toast, setToast] = useState('')
|
||
const [navOpen, setNavOpen] = useState(false)
|
||
const timerRef = useRef(null)
|
||
const toastRef = useRef(null)
|
||
const hasStarted = useRef(false)
|
||
|
||
const showToast = (msg) => {
|
||
setToast(msg)
|
||
clearTimeout(toastRef.current)
|
||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
const [quizRes, voicesRes] = await Promise.all([
|
||
api.get(`/quizzes/${id}`),
|
||
api.get('/tts/voices').catch(() => ({ data: [] })),
|
||
])
|
||
setQuiz(quizRes.data)
|
||
setVoices(voicesRes.data)
|
||
const def = voicesRes.data.find(v => v.is_default)
|
||
if (def) setSelectedVoice(def.id)
|
||
} catch { navigate('/') }
|
||
finally { setLoading(false) }
|
||
}
|
||
load()
|
||
return () => clearInterval(timerRef.current)
|
||
}, [id])
|
||
|
||
const startQuiz = async (mode, voice) => {
|
||
if (hasStarted.current) return
|
||
hasStarted.current = true
|
||
setSelectedVoice(voice)
|
||
setQuizMode(mode)
|
||
|
||
// Fetch full question data with study mode if needed
|
||
try {
|
||
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
|
||
setQuiz(quizRes.data)
|
||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||
setAttemptId(attemptRes.data.id)
|
||
if (mode === 'exam' && quizRes.data.time_limit_minutes) {
|
||
const secs = quizRes.data.time_limit_minutes * 60
|
||
setTimeLeft(secs); setTotalTime(secs)
|
||
}
|
||
} catch { navigate('/') }
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (timeLeft === null) return
|
||
if (timeLeft <= 0) { handleSubmit(true); return }
|
||
timerRef.current = setInterval(() => {
|
||
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
|
||
}, 1000)
|
||
return () => clearInterval(timerRef.current)
|
||
}, [timeLeft === null])
|
||
|
||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||
|
||
const safeNavigate = (targetIdx) => {
|
||
setCurrentIdx(targetIdx)
|
||
setTimeout(() => {
|
||
document.querySelector('.question-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}, 50)
|
||
}
|
||
|
||
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||
if (!attemptId || submitting) return
|
||
if (!autoSubmit && Object.keys(answers).length === 0) {
|
||
showToast('No answers selected — submitting with 0 answered.')
|
||
await new Promise(r => setTimeout(r, 1200))
|
||
}
|
||
clearInterval(timerRef.current)
|
||
setSubmitting(true)
|
||
try {
|
||
const submission = {
|
||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||
question_id: parseInt(qid), user_answer: answer,
|
||
})),
|
||
}
|
||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||
} catch (err) {
|
||
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
|
||
} finally { setSubmitting(false) }
|
||
}, [attemptId, answers, submitting])
|
||
|
||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||
if (!quiz) return null
|
||
if (!quizMode) return (
|
||
<div>
|
||
{isModerator && (
|
||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||
</div>
|
||
)}
|
||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
||
</div>
|
||
)
|
||
|
||
const isStudy = quizMode === 'study'
|
||
const questions = quiz.questions || []
|
||
const current = questions[currentIdx]
|
||
const answeredCount = Object.keys(answers).length
|
||
const totalCount = questions.length
|
||
const isLast = currentIdx === totalCount - 1
|
||
|
||
const QuestionDot = ({ q, i }) => {
|
||
const isActive = i === currentIdx
|
||
const isDone = !!answers[q.id]
|
||
return (
|
||
<button key={q.id} onClick={() => { safeNavigate(i); setNavOpen(false) }} style={{
|
||
width: 34, height: 34, borderRadius: '50%', border: isActive ? '2px solid var(--primary)' : 'none',
|
||
cursor: 'pointer', fontSize: '0.78rem', fontWeight: 600, flexShrink: 0,
|
||
background: isActive ? 'var(--primary)' : isDone ? 'var(--correct-bg)' : 'var(--border)',
|
||
color: isActive ? 'white' : isDone ? 'var(--correct-fg)' : 'var(--text-muted)',
|
||
transition: 'background 0.1s',
|
||
}}>{i + 1}</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="quiz-bottom">
|
||
{toast && (
|
||
<div style={{
|
||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||
background: 'rgba(15,23,42,0.92)', color: '#f1f5f9',
|
||
padding: '10px 20px', borderRadius: 24, fontSize: '0.85rem',
|
||
zIndex: 500, whiteSpace: 'nowrap', pointerEvents: 'none',
|
||
animation: 'fadeInUp 0.2s ease',
|
||
}}>
|
||
{toast}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="card" style={{ marginBottom: 14 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||
<div>
|
||
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
|
||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{
|
||
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
|
||
}}>
|
||
{isStudy ? '📖 Study' : '🎯 Exam'}
|
||
</span>
|
||
<span>Q {currentIdx + 1} / {totalCount}</span>
|
||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||
</div>
|
||
</div>
|
||
{voices.length > 1 && (
|
||
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊</label>
|
||
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
|
||
disabled={ttsActive}
|
||
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.78rem', opacity: ttsActive ? 0.5 : 1, background: 'var(--input-bg)', color: 'var(--text)' }}>
|
||
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="progress-bar" style={{ marginBottom: 16 }}>
|
||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||
</div>
|
||
|
||
{/* Two-column layout: content + desktop sidebar */}
|
||
<div className="quiz-layout">
|
||
{/* Main content */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{current && (
|
||
<div className="question-card">
|
||
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||
{voices.length > 0 && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<TTSButton
|
||
key={`${current.id}_${answers[current.id] || ''}`}
|
||
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||
voice={selectedVoice}
|
||
onActiveChange={setTtsActive}
|
||
/>
|
||
</div>
|
||
)}
|
||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
|
||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||
</span>
|
||
{current.image_path && (
|
||
<div style={{ margin: '10px 0' }}>
|
||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||
onError={e => e.target.style.display = 'none'} />
|
||
</div>
|
||
)}
|
||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||
<div className="options" style={{ marginTop: 8 }}>
|
||
{current.options.map((opt, i) => {
|
||
const isSelected = answers[current.id] === opt
|
||
const hasAnswered = isStudy && !!answers[current.id]
|
||
const isCorrectOpt = opt === current.correct_answer
|
||
const showCorrect = hasAnswered && isCorrectOpt
|
||
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||
const letter = String.fromCharCode(65 + i)
|
||
return (
|
||
<div key={i}
|
||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||
<span className="option-letter">{letter}</span>
|
||
<span style={{ flex: 1 }}>{opt}</span>
|
||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<input type="text" placeholder="Type your answer..."
|
||
value={answers[current.id] || ''}
|
||
onChange={e => setAnswer(current.id, e.target.value)}
|
||
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||
)}
|
||
{isStudy && answers[current.id] && (
|
||
<>
|
||
{current.explanation && (
|
||
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||
<strong>Explanation:</strong> {current.explanation}
|
||
</div>
|
||
)}
|
||
{current.question_type === 'fill_blank' && (
|
||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||
<strong>Correct Answer:</strong> {current.correct_answer}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Prev / Next + mobile nav toggle */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
|
||
<button className="btn btn-secondary"
|
||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||
disabled={currentIdx === 0}>← Prev</button>
|
||
|
||
{/* Mobile-only nav toggle */}
|
||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||
onClick={() => setNavOpen(v => !v)}>
|
||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||
</button>
|
||
|
||
{isLast ? (
|
||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||
</button>
|
||
) : (
|
||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile: collapsible number grid */}
|
||
{navOpen && (
|
||
<div className="quiz-nav-mobile-grid">
|
||
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
|
||
</div>
|
||
)}
|
||
|
||
{answeredCount > 0 && !isLast && (
|
||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||
<button className="btn btn-secondary btn-sm" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||
Submit now ({answeredCount}/{totalCount} answered)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Desktop sidebar */}
|
||
<div className="quiz-sidebar">
|
||
<div style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: 12 }}>
|
||
Questions
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
|
||
</div>
|
||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginRight: 4 }} />{answeredCount} answered</span>
|
||
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--border)', marginRight: 4 }} />{totalCount - answeredCount} remaining</span>
|
||
</div>
|
||
{answeredCount > 0 && !isLast && (
|
||
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
|
||
onClick={() => handleSubmit(false)} disabled={submitting}>
|
||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|