/* quiz.jsx — QuizMode, QuizResults */
// Feature flag — AI tutor (Claude) is disabled in self-hosted builds.
// To enable as a Pro/paid feature, see docs/AI_TUTOR_PAID_FEATURE.md
const AI_TUTOR_ENABLED = false;
function shuffle(arr){return[...arr].sort(()=>Math.random()-0.5);}
function normCmd(s){return s.trim().toLowerCase().replace(/\s+/g,' ');}
function DomTag({d}){const dom=(window.DOMAINS||[]).find(x=>x.id===d);return {dom?.short||`D${d}`} ;}
function DiffTag({diff,ccnp}){if(ccnp)return CCNP ;return {diff===1?'Easy':diff===2?'Med':'Hard'} ;}
function Exhibit({text}){
return(
);
}
function OptBtn({opt,state,onClick}){
const cls=['q-opt',...(state?state.split(' '):[])].filter(Boolean).join(' ');
return({opt.id.toUpperCase()} {opt.t} );
}
function MCQ({q,selected,onSelect,submitted}){
return({q.opts.map(o=>{
let s='';
if(submitted){s='disabled';if(q.ans.includes(o.id))s='correct disabled';else if(selected.includes(o.id))s='wrong disabled';}
else if(selected.includes(o.id))s='selected';
return !submitted&&onSelect(o.id)}/>;
})}
);
}
function MSQ({q,selected,onToggle,submitted}){
return(
▲ Select all that apply
{q.opts.map(o=>{
let s='';
if(submitted){const isA=q.ans.includes(o.id),isSel=selected.includes(o.id);s='disabled';if(isA)s='correct disabled';else if(isSel&&!isA)s='wrong disabled';else if(!isSel&&isA)s='missed disabled';}
else if(selected.includes(o.id))s='selected';
return
!submitted&&onToggle(o.id)}/>;
})}
);
}
function FITBQ({q,vals,onChange,submitted,correctness}){
return(
{(q.blanks||[]).map((blank,i)=>{
const prompt=i===0?q.fitbPrompt:(q.fitbPrompt2||q.fitbPrompt);
let cls='fitb-input';if(submitted)cls+=correctness[i]?' ok':' err';
return(
{prompt}
onChange(i,e.target.value)}
placeholder="type command…" disabled={submitted} spellCheck={false} autoComplete="off"/>
);
})}
{submitted&&
{(q.blanks||[]).map((b,i)=>!correctness[i]&&
Line {i+1}: {b}
)}
}
);
}
// Short-answer (type:"sa"). User types a free-text answer; graded
// case-insensitively against q.acceptable (falling back to q.ans). Reuses the
// .fitb-input styling so it matches the fill-in-the-blank look.
function SAQ({q,val,onChange,submitted,correct,onEnter}){
let cls='fitb-input sa-input';if(submitted)cls+=correct?' ok':' err';
const canonical=(q.ans&&q.ans[0])||(q.acceptable&&q.acceptable[0])||'';
return(
onChange(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault();if(!submitted&&onEnter)onEnter();}}}
placeholder="type your answer…" disabled={submitted} spellCheck={false} autoComplete="off" autoFocus/>
{submitted&&!correct&&
Answer: {canonical}
}
);
}
function MatchQ({q,termSel,defSel,pairs,onTerm,onDef,defs,submitted}){
return(
Terms — click to select
{q.pairs.map((p,i)=>{const pa=pairs.find(x=>x.ti===i);let cls='match-item';if(termSel===i)cls+=' active';if(pa)cls+=' paired';return(
!submitted&&!pa&&onTerm(i)} disabled={submitted||!!pa}>
{String.fromCharCode(65+i)}. {p.term}
);
})}
Definitions — click to pair
{defs.map((def,i)=>{const pa=pairs.find(x=>x.di===i);let cls='match-item';if(defSel===i)cls+=' active';if(pa)cls+=' paired';return(
!submitted&&!pa&&onDef(i)} disabled={submitted||!!pa}>{def} );
})}
{pairs.length===q.pairs.length&&!submitted&&
✓ All matched — click Submit
}
{submitted&&
Match Results
{q.pairs.map((p,i)=>{const pa=pairs.find(x=>x.ti===i);const ok=pa&&defs[pa.di]===p.def;return(
{ok?'✓':'✗'} {p.term} → {p.def}
);
})}
}
);
}
// ===== Report-a-question modal =====
// Used in Feedback (practice mode, post-answer) and QuizResults (final exam review).
// Requires the user to be logged in — modal short-circuits to a sign-in prompt
// when AuthStore reports no user. Posts to /api/exam-feedback.
function ReportQuestionModal({q, source, certHint, onClose}){
const [issueType, setIssueType] = React.useState('wrong_answer');
const [description, setDescription] = React.useState('');
const [status, setStatus] = React.useState('idle'); // idle | sending | sent | error
const [errorMsg, setErrorMsg] = React.useState('');
const user = (window.AuthStore && window.AuthStore.user) ? window.AuthStore.user : null;
const isLoggedIn = !!user;
async function submit(){
if (!description.trim()) return;
setStatus('sending');
setErrorMsg('');
try {
const api = window.AuthStore && window.AuthStore.api;
if (!api) {
setErrorMsg('Auth not loaded — refresh the page and try again.');
setStatus('error');
return;
}
const stem = (q.stem || q.question || '').slice(0, 1900);
// AuthStore.api returns the parsed JSON body directly (and throws on
// non-2xx). The backend always returns {ok: bool, message: str} for
// this endpoint, even on send failure.
const data = await api('/api/exam-feedback', {
method: 'POST',
body: JSON.stringify({
cert: certHint || q.exam || 'unknown',
question_id: String(q.id),
source: source || 'practice',
question_stem: stem,
issue_type: issueType,
description: description.trim(),
}),
});
if (data && data.ok) {
setStatus('sent');
setTimeout(onClose, 1600);
} else {
setErrorMsg((data && data.message) || 'Send failed.');
setStatus('error');
}
} catch (e) {
setErrorMsg(e.message || 'Network error.');
setStatus('error');
}
}
return (
e.stopPropagation()}>
Report a problem with this question
×
{!isLoggedIn ? (
You need to be signed in to report a question. Sign in and try again.
Close
) : (
Question ID: {q.id}
Cert: {certHint || q.exam || 'unknown'}
What's the issue?
setIssueType(e.target.value)} disabled={status==='sending'||status==='sent'}>
Wrong answer
Typo or formatting
Ambiguous wording
Outdated information
Explanation is unclear or wrong
Other
Details
)}
);
}
function Feedback({q,selected,correct}){
const [reportOpen, setReportOpen] = React.useState(false);
const certHint = q.exam || (window.UserData && window.UserData.getCertMode ? window.UserData.getCertMode() : null);
return(
{correct?'✓ Correct':'✗ Incorrect'}
{q.exp}
{!correct&&q.wrong&&
{selected.filter(s=>!q.ans.includes(s)).map(s=>q.wrong[s]&&(
{s.toUpperCase()}: {q.wrong[s]}
))}
}
setReportOpen(true)} title="Report a problem with this question">
⚑ Report this question
{reportOpen &&
setReportOpen(false)}/>}
);
}
function HintPanel({q,hintsShown,onHint,chatLog,chatIn,setChatIn,onAsk,loading}){
const logRef=React.useRef(null);
React.useEffect(()=>{if(logRef.current)logRef.current.scrollTop=logRef.current.scrollHeight;},[chatLog,loading]);
const maxHints=q.hints?.length||0;
return(
{AI_TUTOR_ENABLED?'AI Tutor':'Hints'} {AI_TUTOR_ENABLED&&Claude }
{hintsShown>0&&
{q.hints.slice(0,hintsShown).map((h,i)=>
Hint {i+1}/{maxHints} {h}
)}
}
{hintsShown
+ Reveal hint ({hintsShown}/{maxHints})
}
{AI_TUTOR_ENABLED &&
{chatLog.length===0&&
Ask me anything… I won't reveal the answer until you submit.
}
{chatLog.map((m,i)=>
{m.c}
)}
{loading&&
thinking…
}
}
);
}
function QuizMode({session,onComplete,onAnswer,onExit}){
const{questions}=session;const total=questions.length;
const[idx,setIdx]=React.useState(0);
const[selected,setSelected]=React.useState([]);
const[fitbVals,setFitbVals]=React.useState([]);
const[fitbCorrect,setFitbCorrect]=React.useState([]);
const[saVal,setSaVal]=React.useState('');
const[shuffDefs,setShuffDefs]=React.useState([]);
const[matchPairs,setMatchPairs]=React.useState([]);
const[termSel,setTermSel]=React.useState(null);
const[defSel,setDefSel]=React.useState(null);
const[submitted,setSubmitted]=React.useState(false);
const[answers,setAnswers]=React.useState({});
const[hintsShown,setHintsShown]=React.useState({});
const[chatLogs,setChatLogs]=React.useState({});
const[chatIn,setChatIn]=React.useState('');
const[loading,setLoading]=React.useState(false);
const[timeLeft,setTimeLeft]=React.useState(session.timerEnabled?session.timerDuration*60:null);
const[learnTopicId,setLearnTopicId]=React.useState(null);
const q=questions[idx];
const chatLog=chatLogs[q?.id]||[];
const hShown=hintsShown[q?.id]||0;
React.useEffect(()=>{
if(!session.timerEnabled||timeLeft===null||timeLeft<=0)return;
const t=setTimeout(()=>setTimeLeft(tl=>tl-1),1000);return()=>clearTimeout(t);
},[timeLeft,session.timerEnabled]);
React.useEffect(()=>{
setSelected([]);setFitbVals([]);setFitbCorrect([]);setMatchPairs([]);setSaVal('');
setTermSel(null);setDefSel(null);setSubmitted(false);setChatIn('');
if(q?.type==='match')setShuffDefs(shuffle(q.pairs.map(p=>p.def)));
},[idx]);
function toggle(id){setSelected(prev=>q.type==='ms'?(prev.includes(id)?prev.filter(x=>x!==id):[...prev,id]):[id]);}
function handleMatchTerm(ti){if(defSel!==null){setMatchPairs(p=>[...p,{ti,di:defSel}]);setTermSel(null);setDefSel(null);}else setTermSel(ti);}
function handleMatchDef(di){if(termSel!==null){setMatchPairs(p=>[...p,{ti:termSel,di}]);setTermSel(null);setDefSel(null);}else setDefSel(di);}
function canSubmit(){
if(submitted)return false;
if(q.type==='mc'||q.type==='exhibit')return selected.length===1;
if(q.type==='ms')return selected.length>=1;
if(q.type==='fitb')return(q.blanks||[]).every((_,i)=>(fitbVals[i]||'').trim().length>0);
if(q.type==='sa')return(saVal||'').trim().length>0;
if(q.type==='match')return matchPairs.length===q.pairs.length;
// 'dd' handled by DragDropQuestion's own submit button — outer submit hidden
return false;
}
// Drag-and-drop submit handler — called by DragDropQuestion when user clicks
// its internal "Submit answer" button. result.score is 0..1; we record full
// marks (1.0) as correct, anything less as incorrect for consistency with
// existing binary scoring.
function handleDDSubmit(result){
const correct = result.score === 1;
setSubmitted(true);
setAnswers(prev=>({...prev,[q.id]:{correct,selected:[],ddScore:result.score}}));
if(onAnswer)onAnswer(q,correct);
}
function doSubmit(){
let correct=false;let fc=[];
if(q.type==='mc'||q.type==='exhibit')correct=selected.length===q.ans.length&&selected.every(s=>q.ans.includes(s));
else if(q.type==='ms')correct=selected.length===q.ans.length&&selected.every(s=>q.ans.includes(s));
else if(q.type==='fitb'){fc=(q.blanks||[]).map((b,i)=>normCmd(fitbVals[i]||'')===normCmd(b));correct=fc.every(Boolean);setFitbCorrect(fc);}
else if(q.type==='sa'){const acc=(Array.isArray(q.acceptable)&&q.acceptable.length?q.acceptable:(q.ans||[]));correct=acc.some(a=>normCmd(String(a))===normCmd(saVal||''));}
else if(q.type==='match')correct=matchPairs.length===q.pairs.length&&matchPairs.every(({ti,di})=>shuffDefs[di]===q.pairs[ti].def);
setSubmitted(true);
setAnswers(prev=>({...prev,[q.id]:{correct,selected:[...selected]}}));
if(onAnswer)onAnswer(q,correct);
}
function doNext(){if(idxi+1);else onComplete(answers);}
function handleSkip(){doNext();}
async function doAsk(){
if(!chatIn.trim()||loading)return;
const msg=chatIn.trim();setChatIn('');
const prev=chatLogs[q.id]||[];
const newLog=[...prev,{r:'user',c:msg}];
setChatLogs(p=>({...p,[q.id]:newLog}));
setLoading(true);
try{
const sys=`You are a concise CCNA/CCNP exam tutor. Topic: ${q.topic}. Question: "${q.stem}".${!submitted?' Do NOT reveal the correct answer option. Give Socratic guidance — ask leading questions, don\'t state the answer directly.':' Student submitted. Explain fully.'} Max 3 sentences.`;
const msgs=newLog.map(m=>({role:m.r==='bot'?'assistant':'user',content:m.c}));
const reply=await window.claude.complete({system:sys,messages:msgs});
setChatLogs(p=>({...p,[q.id]:[...newLog,{r:'bot',c:reply}]}));
}catch(e){setChatLogs(p=>({...p,[q.id]:[...newLog,{r:'bot',c:'Claude unavailable — check your connection.'}]}));}
setLoading(false);
}
const isCorrect=answers[q?.id]?.correct;
const fmtTime=s=>`${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;
if(!q)return(
No questions are available for this selection yet.
← Back
);
// ===== CLI QUESTION TYPE =====
if(q.type==='cli'){
return(
CLI {idx+1} / {total}
{session.label}
✕ Exit
{q.topic}
{setAnswers(prev=>({...prev,[q.id]:{correct,selected:[]}}));setSubmitted(true);if(onAnswer)onAnswer(q,correct);}}
submitted={submitted}
onSkip={!submitted?handleSkip:null}
onLearnTopic={q.conceptId ? ()=>setLearnTopicId(q.conceptId) : null}/>
{submitted&&
{idx}
{!submitted&&↑↓ history · ? help
}
{learnTopicId&&setLearnTopicId(null)}/>}
);
}
return(
Q {idx+1} / {total}
{session.label}
{session.timerEnabled&&timeLeft!==null&&{fmtTime(timeLeft)} }
✕ Exit
{q.topic}
{q.stem}
{q.exhibit&&
}
{(q.type==='mc'||q.type==='exhibit')&&}
{q.type==='ms'&&}
{q.type==='fitb'&&setFitbVals(p=>{const n=[...p];n[i]=v;return n;})} submitted={submitted} correctness={fitbCorrect}/>}
{q.type==='sa'&&{if(canSubmit())doSubmit();}}/>}
{q.type==='match'&&}
{q.type==='dd'&&}
{q.type!=='dd' && !submitted
?Submit Answer
:q.type!=='dd' && submitted
?{idx
:null}
{q.type==='dd' && submitted && {idx}
{!submitted&&q.type!=='dd'&&setHintsShown(p=>({...p,[q.id]:Math.min((p[q.id]||0)+1,q.hints?.length||0)}))}>Hint }
{!submitted&&Skip → }
{submitted&&q.type!=='dd'&&}
setHintsShown(p=>({...p,[q.id]:Math.min((p[q.id]||0)+1,q.hints?.length||0)}))}
chatLog={chatLog} chatIn={chatIn} setChatIn={setChatIn} onAsk={doAsk} loading={loading}/>
);
}
function QuizResults({session,finalAnswers,onHome,onRetry}){
const{questions}=session;
const correct=questions.filter(q=>finalAnswers[q.id]?.correct).length;
const answered=questions.filter(q=>finalAnswers[q.id]).length;
const skipped=questions.length-answered;
const pct=answered>0?Math.round((correct/answered)*100):0;
const pass=pct>=70;
const ds={};window.DOMAINS.forEach(d=>ds[d.id]={c:0,t:0});
questions.forEach(q=>{ds[q.d].t++;if(finalAnswers[q.id]?.correct)ds[q.d].c++;});
return(
Session Complete
← Home
Final Score
{pct}%
{correct}/{answered} correct{skipped>0?` · ${skipped} skipped`:''} · {pass?'✓ Pass':'Keep studying'}
{window.DOMAINS.map(d=>{const s=ds[d.id];if(!s.t)return null;const p=Math.round((s.c/s.t)*100);return(
Retry Session
Home
Question Review
{questions.map((q,i)=>{const ans=finalAnswers[q.id];const pipCls=ans?.correct?'ok':ans?'bad':'skip';return(
Q{i+1}. {q.stem.slice(0,68)}{q.stem.length>68?'…':''}
{q.topic.split(' ')[0]}
);})}
);
}
Object.assign(window,{QuizMode,QuizResults,ReportQuestionModal});