/* app.jsx — cert selection screen on load, no "both", no in-Practice cert toggle */
const PROG_KEY_PREFIX='ccna_progress_v2_';
const CERT_KEY='ccna_cert_mode';
// Theme picker — 6 site-wide color schemes. Default is "green" (no data-theme
// attribute set on ; falls through to :root values). Other themes are
// applied via and styled in styles.css.
// Persistence: localStorage["cyberstudy.theme"]. Applied early (before first
// paint) by an inline script in index.html, then synced via setTheme below.
const THEME_STORAGE_KEY = 'cyberstudy.theme';
const THEMES = [
{ key: 'green', label: 'Green', swatch: 'oklch(0.87 0.260 148)' },
{ key: 'blue', label: 'Blue', swatch: 'oklch(0.78 0.180 250)' },
{ key: 'orange', label: 'Orange', swatch: 'oklch(0.80 0.180 55)' },
{ key: 'red', label: 'Red', swatch: 'oklch(0.72 0.220 25)' },
{ key: 'purple', label: 'Purple', swatch: 'oklch(0.72 0.200 300)' },
{ key: 'mono', label: 'B/W', swatch: null /* CSS class .mono renders the split */ },
];
function applyTheme(key) {
const safe = THEMES.some(t => t.key === key) ? key : 'green';
if (safe === 'green') {
delete document.documentElement.dataset.theme;
} else {
document.documentElement.dataset.theme = safe;
}
try { localStorage.setItem(THEME_STORAGE_KEY, safe); } catch (_) { /* storage disabled */ }
}
function getStoredTheme() {
try {
const t = localStorage.getItem(THEME_STORAGE_KEY);
return THEMES.some(x => x.key === t) ? t : 'green';
} catch (_) { return 'green'; }
}
// Reusable theme picker. Self-contained (owns its own state, reads/writes
// localStorage via getStoredTheme/applyTheme), so it can be dropped into any
// view. Exposed on window so other bundles (profile.jsx) can render it. Now
// lives in the Profile → Appearance tab; previously inlined on the home page.
function ThemePicker(){
const [theme, setThemeState] = React.useState(() => getStoredTheme());
function setTheme(key){
setThemeState(key);
applyTheme(key);
}
return(
{THEMES.map(t => (
))}
);
}
window.ThemePicker = ThemePicker;
function defaultProg(){const ds={};(window.DOMAINS||[]).forEach(d=>ds[d.id]={answered:0,correct:0});return{totalAnswered:0,totalCorrect:0,domainStats:ds,missedIds:[],streak:0,lastStudied:null,sessionCount:0};}
function loadProg(examId){const p=window.UserData?window.UserData.getProgress(examId):null;return p||defaultProg();}
function saveProg(examId,p){if(window.UserData)window.UserData.setProgress(examId,p);}
function updateProg(prog,session,finalAnswers){
const p=JSON.parse(JSON.stringify(prog));
const today=new Date().toDateString();const yesterday=new Date(Date.now()-86400000).toDateString();
if(p.lastStudied!==today){p.streak=p.lastStudied===yesterday?p.streak+1:1;p.lastStudied=today;}
session.questions.forEach(q=>{const ans=finalAnswers[q.id];if(!ans)return;p.totalAnswered++;if(!p.domainStats[q.d])p.domainStats[q.d]={answered:0,correct:0};p.domainStats[q.d].answered++;if(ans.correct){p.totalCorrect++;p.domainStats[q.d].correct++;p.missedIds=p.missedIds.filter(id=>id!==q.id);}else{if(!p.missedIds.includes(q.id))p.missedIds.push(q.id);}});
p.sessionCount++;return p;
}
function buildSession(mode, domainId, tweaks, certMode){
let pool=(window.Q||[]).filter(q=>q.exam===certMode);
if(mode==='cli') pool=pool.filter(q=>q.type==='cli');
else if(mode!=='full') pool=pool.filter(q=>q.type!=='cli');
if(mode==='quick'){ const qs=pool.sort(()=>Math.random()-0.5).slice(0,10); return{questions:qs,label:'Quick Drill',timerEnabled:tweaks.timerOn,timerDuration:tweaks.timerMins}; }
if(mode==='weak'){ const prog=loadProg();const missed=prog.missedIds||[];const mq=pool.filter(q=>missed.includes(q.id));const rest=pool.filter(q=>!missed.includes(q.id)).sort(()=>Math.random()-0.5);const qs=[...mq,...rest].slice(0,20);return{questions:qs.length?qs:pool.sort(()=>Math.random()-0.5).slice(0,10),label:'Weak Spot Review',timerEnabled:tweaks.timerOn,timerDuration:tweaks.timerMins}; }
if(mode==='domain'){ const target=100; const domainPool=pool.filter(q=>q.d===domainId); const qs=domainPool.sort(()=>Math.random()-0.5).slice(0,target); const name=(window.DOMAINS||[]).find(d=>d.id===domainId)?.name||'Domain'; return{questions:qs.length?qs:pool.sort(()=>Math.random()-0.5).slice(0,10),label:`${name} — ${qs.length}q`,timerEnabled:tweaks.timerOn,timerDuration:tweaks.timerMins}; }
if(mode==='cli'){ return{questions:pool.sort(()=>Math.random()-0.5),label:'CLI Drills',timerEnabled:false,timerDuration:0}; }
return{questions:pool.sort(()=>Math.random()-0.5),label:'Full Session',timerEnabled:tweaks.timerOn,timerDuration:tweaks.timerMins};
}
/* ===== CERT SELECTION SCREEN (initial choice) ===== */
function CertSelectScreen({onPick,onSignInClick,onOpenCareer,certMode}){
const exams = window.EXAMS || [];
const isLoggedIn = !!(window.AuthStore && window.AuthStore.isLoggedIn);
const user = (window.AuthStore && window.AuthStore.user) || null;
const username = (user && (user.username || user.name)) || 'guest';
// Compute cert IDs on the user's currently-enrolled career path so we can
// surface a "↳ on your path" hint on matching cards. Subscribe to changes
// so the chooser stays in sync if the user enrolls in another tab.
const [, _bumpForPath] = React.useState(0);
React.useEffect(() => {
if (!window.UserData) return;
const refresh = () => _bumpForPath(n => n + 1);
window.addEventListener(window.UserData.EVT_ENROLL || "cyberstudy-enrollment-change", refresh);
window.addEventListener(window.UserData.EVT_PLAN || "cyberstudy-plan-change", refresh);
window.addEventListener(window.UserData.EVT_HYDRATE || "ccna-user-data-hydrated", refresh);
return () => {
window.removeEventListener(window.UserData.EVT_ENROLL || "cyberstudy-enrollment-change", refresh);
window.removeEventListener(window.UserData.EVT_PLAN || "cyberstudy-plan-change", refresh);
window.removeEventListener(window.UserData.EVT_HYDRATE || "ccna-user-data-hydrated", refresh);
};
}, []);
const enrollment = window.UserData?.getCareerEnrollment();
const enrolledCareer = enrollment && (window.CAREER_PATHS || []).find(c => c.id === enrollment.careerId);
const customPlan = enrollment ? window.UserData?.getCareerPlan(enrollment.careerId) : null;
const onPathCertIds = new Set(
enrolledCareer
? (customPlan ? customPlan.certIds : enrolledCareer.defaultCerts.map(d => d.certId))
: []
);
// Under-development cert cards — sourced from window.CERT_META entries
// tagged comingSoon:true. Order: least → most specialized.
const UNDER_DEV_ORDER = [];
const underDev = UNDER_DEV_ORDER
.map(id => ({ id, meta: (window.CERT_META || {})[id] }))
.filter(x => x.meta && x.meta.comingSoon);
// Tell-us-this-matters button on under-dev cards. Same endpoint and
// issue_type as the career-planner version so triage stays consistent.
const [tellUsStatus, setTellUsStatus] = React.useState(null);
const [tellUsMsg, setTellUsMsg] = React.useState('');
async function sendCertTellUs(certId, certLabel) {
if (!isLoggedIn) { onSignInClick(); return; }
const api = window.AuthStore && window.AuthStore.api;
setTellUsStatus('sending');
try {
const data = await api('/api/exam-feedback', {
method: 'POST',
body: JSON.stringify({
cert: certId,
question_id: 'cert-priority-request',
source: 'chooser_under_dev_card',
question_stem: 'User signaled priority for upcoming cert: ' + certLabel,
issue_type: 'cert_priority_request',
description: 'User clicked "Tell us this matters" on the under-development card for ' + certLabel + ' (' + certId + ') from the cert chooser.',
}),
});
if (data && data.ok) {
setTellUsStatus('sent');
setTellUsMsg('Thanks — we\'ll prioritize ' + certLabel + '.');
setTimeout(() => { setTellUsStatus(null); setTellUsMsg(''); }, 3500);
} else {
setTellUsStatus('error');
setTellUsMsg((data && data.message) || 'Send failed.');
setTimeout(() => { setTellUsStatus(null); setTellUsMsg(''); }, 3500);
}
} catch (e) {
setTellUsStatus('error');
setTellUsMsg(e.message || 'Network error.');
setTimeout(() => { setTellUsStatus(null); setTellUsMsg(''); }, 3500);
}
}
const CERT_ICONS = { aplus1:"🖥", aplus2:"⚙", netplus:"🌐", secplus:"🛡", ccna:"🔧", ccnp:"📡", linuxplus:"🐧", cloudplus:"☁", cysaplus:"🔍", pentestplus:"🎯", securityx:"🔐" };
return(
Certification Tracks
Each track gives you concept pages to read, a question bank to drill, and a timed final-exam simulator. Pick the cert you're studying for — you can switch anytime from the badge in the top bar.
{CERT_ICONS[id]||"🔒"}{meta.label||id}Under Development
{meta.blurb}
Exam code{meta.vendor} {meta.examCode}
))}
{window.FeedbackRequestCertBtn && (
)}
);
}
/* ===== CERT PREVIEW SCREEN =====
Reached by clicking a cert on the chooser (/certs/). Shows what the
track contains and offers Enroll / Back. The chooser sends the user straight
to Learn (resume) instead of here when they click the cert they're already
studying — so this screen is only ever for a cert the user is NOT on. */
function CertPreviewScreen({certId,onEnroll,onBack,isLoggedIn,onSignInClick}){
const exam = (window.EXAMS||[]).find(e=>e.id===certId);
const meta = (window.CERT_META||{})[certId] || {};
const CERT_ICONS = { aplus1:"🖥", aplus2:"⚙", netplus:"🌐", secplus:"🛡", ccna:"🔧", ccnp:"📡", linuxplus:"🐧", cloudplus:"☁", cysaplus:"🔍", pentestplus:"🎯", securityx:"🔐" };
// Unknown cert id (hand-typed URL, retired cert) — bounce back to chooser.
if(!exam){
return (
);
}
/* ===== TOPBAR ===== */
// Builds the parts of the terminal-style brand: username@cyberstudy:~/path $ ▌
// - username: AuthStore.user.username if logged in, else "guest"
// - path: derived from the current view (/learn → "~/learn"; / → "~"; etc.)
// - long subroutes ellipsize the slug portion to keep the brand compact
// Returns { username, pathDisplay, pathFull } where pathFull is the
// untruncated string (used for the tooltip).
function _useBrandParts(view, certMode) {
// Re-read auth on every render — cheap and avoids stale username after login/logout.
const user = (window.AuthStore && window.AuthStore.user) || null;
const username = (user && (user.username || user.name)) || 'guest';
// Map view → base path segment. Mirrors router VIEW_TO_PATH but kept
// local so we don't have to import the router for this.
const VIEW_TO_SEGMENT = {
'home': '',
'learn': 'learn',
'practice': 'practice',
'progress': 'progress',
'notes': 'notes',
'final-exam': 'final-exam',
'cli': 'cli',
'login': 'login',
'signup': 'signup',
'forgot': 'forgot',
'reset': 'reset',
'account': 'account',
'career': 'career',
'cert-home': 'cert',
'certs': 'certs',
'profile': 'profile',
};
let segments = [];
const baseSeg = VIEW_TO_SEGMENT[view];
if (baseSeg) segments.push(baseSeg);
// For career sub-routes (/career/, /career/compare) extend with the
// actual URL slug so the prompt reflects exactly where the user is.
if (view === 'career' && window.Router && window.Router.currentCareerRoute) {
const sub = window.Router.currentCareerRoute();
if (sub && sub.kind === 'detail' && sub.careerId) segments.push(sub.careerId);
else if (sub && sub.kind === 'compare') segments.push('compare');
}
// Build path strings. Tilde always — we're pretending this is a user's
// home directory.
const pathFull = segments.length === 0 ? '~' : '~/' + segments.join('/');
// Truncate the LAST segment if the total prompt is too long. Cutoff at
// 18 chars for the last segment, ellipsize from the middle so both ends
// stay recognizable (career/cyber-an…st rather than career/cyber-anal…).
let pathDisplay = pathFull;
if (segments.length > 0) {
const last = segments[segments.length - 1];
if (last.length > 18) {
const head = last.slice(0, 9);
const tail = last.slice(-6);
const truncated = head + '…' + tail;
const front = segments.slice(0, -1);
pathDisplay = '~/' + (front.length ? front.join('/') + '/' : '') + truncated;
}
}
return { username, pathDisplay, pathFull };
}
function TopBar({activeTab,setView,prog,streak,certMode,onSwitchCert,onPickCert,view}){
const acc=prog.totalAnswered>0?Math.round((prog.totalCorrect/prog.totalAnswered)*100):0;
// Compact cert label for the top-bar badge: drop the vendor prefix so
// "CompTIA Security+" -> "Security+", "Cisco CCNA" -> "CCNA".
const certShort = certMode
? String(window.getExamLabel ? window.getExamLabel(certMode) : certMode).replace(/^(CompTIA|Cisco)\s+/,'')
: '';
const [menuOpen,setMenuOpen]=React.useState(false);
const menuRef=React.useRef(null);
// Cert quick-switch dropdown (the "Security+ ▾" badge). Opens a menu to
// switch certs inline rather than bouncing to the chooser page. Click-away
// closes it, mirroring the profile (.user-menu) dropdown pattern below.
const [certMenuOpen,setCertMenuOpen]=React.useState(false);
React.useEffect(()=>{
if(!certMenuOpen) return;
// Capture phase: fires before any element's own click handler, so it still
// closes the cert menu even when the target stops propagation — e.g. the
// username/profile menu trigger calls e.stopPropagation(), which would
// otherwise swallow the click and leave the cert menu open.
const onDoc=(e)=>{ if(!e.target.closest('.cert-menu-wrap')) setCertMenuOpen(false); };
document.addEventListener('click',onDoc,true);
return ()=>document.removeEventListener('click',onDoc,true);
},[certMenuOpen]);
// Context-aware primary nav. The study tabs (Learn/Practice/...) only make
// sense once a cert is active; otherwise show the two top-level pillars so
// the strip reflects where the user actually is. Inside Careers, mirror the
// pillar nav and keep "Careers" highlightable.
// Top-level pillars are ALWAYS reachable so a user is never trapped inside a
// cert track. When a cert is active, the study tabs become the primary nav
// and the pillars render as a compact secondary group (and in the mobile
// drawer). With no cert active, the pillars ARE the primary nav.
const pillarTabs = [['home','Home'],['certs','Certifications'],['career','Careers']];
const studyTabs = certMode
? [['cert-home','Dashboard'],['learn','Learn'],['practice','Practice'],['progress','Progress'],['final-exam','Final Exam'],['notes','Notes']]
: [];
// Subscribe to router changes so the brand path updates when career
// subroutes change (catalog ↔ detail ↔ compare) without a full page reload.
const [, _bumpForRouter] = React.useState(0);
React.useEffect(() => {
if (!window.Router) return;
return window.Router.subscribe(() => _bumpForRouter(n => n + 1));
}, []);
const { username, pathDisplay, pathFull } = _useBrandParts(view, certMode);
const promptTitle = `${username}@cyberstudy:${pathFull}`;
// cd .. behavior — pop one segment off the current URL path.
// /career/it-support → /career, /career → /, /learn → /, / → no-op.
// Path-based (not history-based), so it works even after a direct URL load.
const isAtRoot = pathFull === '~';
const cdUp = React.useCallback(() => {
if (isAtRoot) return;
try {
const p = window.location.pathname.replace(/\/+$/, '') || '/';
const segs = p.split('/').filter(Boolean);
segs.pop();
const parent = segs.length === 0 ? '/' : '/' + segs.join('/');
// Use the router so it triggers the scroll-to-top + notify cycle.
// We don't have a "navigate to arbitrary URL" helper, so push the
// history entry ourselves and call subscribe listeners by going
// through Router if possible.
if (window.Router) {
try { window.history.pushState({}, '', parent); } catch(_) { window.location.href = parent; return; }
try { window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); } catch(_) { window.scrollTo(0, 0); }
// Dispatch popstate so the router's subscriber wakes up
window.dispatchEvent(new PopStateEvent('popstate'));
} else {
window.location.href = parent;
}
} catch (_) {}
}, [isAtRoot]);
// (The old top-strip width/compaction logic is gone: nav now lives in a
// left sidebar, so the slim header — hamburger + brand — always fits.)
// Sidebar dismissal: Escape closes it; the scrim handles click-away and a
// nav choice closes it (handleTab). No body scroll-lock — keeping it simple
// avoids stranding a locked scroll if the window crosses the dock breakpoint.
React.useEffect(()=>{
if(!menuOpen) return;
const onKey=(e)=>{ if(e.key==='Escape') setMenuOpen(false); };
document.addEventListener('keydown',onKey);
return()=>document.removeEventListener('keydown',onKey);
},[menuOpen]);
const handleTab=(v)=>{ setView(v); setMenuOpen(false); };
return(
{/* Slim sticky header: hamburger + brand/breadcrumb. All nav lives in the
left sidebar (.sidenav) below. */}
▌
{/* Click-away scrim (overlay mode). Hidden when docked on ultrawide. */}
setMenuOpen(false)} aria-hidden="true">
{/* Left side navigation: pillars, study tabs, cert badge, stats, auth.
Slides in on hamburger toggle; docks permanently on ultrawide. */}
);
}
/* ===== HOME VIEW ===== */
/* ===== HOME LANDING VIEW =====
* Minimal, unconditional landing at /. Renders the same for every user state
* (logged-out, logged-in-no-cert, logged-in-with-cert). Two CTAs:
* - Careers → /career
* - Certifications → /certs (the chooser) always, even if a cert is active
* Below the buttons, an opt-in progress widget for logged-in users who have
* answered at least one question on their current cert.
*/
function HomeLandingView({prog,setView,certMode,isLoggedIn}){
const certLabel = certMode
? (window.getExamLabel ? window.getExamLabel(certMode) : String(certMode).toUpperCase())
: '';
const showProgress = isLoggedIn && !!certMode && prog && prog.totalAnswered > 0;
const acc = showProgress ? Math.round((prog.totalCorrect/prog.totalAnswered)*100) : 0;
// Mastery tile — same metric the Dashboard/cert-home progress rows use, so
// the 4-column .stats-row fills evenly instead of leaving an empty cell.
const mastered = showProgress ? Object.values(prog.domainStats||{}).filter(s=>s.answered>0&&Math.round((s.correct/s.answered)*100)>=80).length : 0;
const domainCount = (window.DOMAINS||[]).length || 6;
// Theme picker moved to Profile → Appearance (window.ThemePicker).
// Spotlights — fetched once from /api/spotlights. Public endpoint, no auth.
// Each slot is either a known cert/career id, or null/missing. Unknown ids
// are silently dropped (renames don't break the page).
const [spotlights, setSpotlights] = React.useState({cert: null, career: null});
React.useEffect(() => {
let cancelled = false;
fetch('/api/spotlights', {credentials: 'same-origin'})
.then(r => r.ok ? r.json() : null)
.then(data => { if (!cancelled && data) setSpotlights({cert: data.cert || null, career: data.career || null}); })
.catch(() => { /* network — silently hide spotlights, no UI noise */ });
return () => { cancelled = true; };
}, []);
const exams = window.EXAMS || [];
const certMeta = window.CERT_META || {};
const careers = window.CAREER_PATHS || [];
const spotlightCert = spotlights.cert
? (exams.find(e => e.id === spotlights.cert) || (certMeta[spotlights.cert] ? {id: spotlights.cert, label: certMeta[spotlights.cert].label, sub: certMeta[spotlights.cert].vendor + ' ' + (certMeta[spotlights.cert].examCode || ''), description: certMeta[spotlights.cert].blurb} : null))
: null;
const spotlightCareer = spotlights.career ? careers.find(c => c.id === spotlights.career) : null;
const hasSpotlights = !!(spotlightCert || spotlightCareer);
function onSpotlightCertClick() {
if (!spotlightCert) return;
const isStudyable = exams.some(e => e.id === spotlightCert.id);
if (isStudyable) {
// Studyable cert: jump to chooser (so the user can enroll), which then
// lands on /cert. Going directly to /cert would only work if they're
// already enrolled in this exact cert.
if (window.UserData && window.UserData.getCertMode() === spotlightCert.id) {
setView('cert-home');
} else {
setView('certs');
}
} else {
// Coming-soon cert (CERT_META entry without an EXAMS row) → /certs to
// show the under-development card with the "Tell us this matters" CTA.
setView('certs');
}
}
function onSpotlightCareerClick() {
if (!spotlightCareer || !window.Router || !window.Router.navigateCareer) {
setView('career');
return;
}
window.Router.navigateCareer({kind: 'detail', careerId: spotlightCareer.id});
}
return(
$show home
Free, ad-free study for CompTIA & Cisco IT certifications — concept pages, question banks, and a timed exam simulator. Preview sample concepts free; create a free account to unlock the full library.
{!isLoggedIn && (
)}
{showProgress && (
<>
Your progress · {certLabel}
Total Answered
{prog.totalAnswered}
Accuracy
=70?'green':'amber'}`}>{acc}%
Streak
{prog.streak>0?`${prog.streak}d`:'—'}
Domains ≥80%
{mastered}/{domainCount}
>
)}
{hasSpotlights && (
<>
Spotlight
{/* Career on left, cert on right — matches the top row (Careers left / Certifications right). */}
{spotlightCareer && (() => {
const pathCerts = (spotlightCareer.defaultCerts || [])
.slice()
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map(d => {
const examLabel = (window.getExamLabel ? window.getExamLabel(d.certId) : null)
|| ((certMeta[d.certId] && certMeta[d.certId].label) || d.certId);
return examLabel;
});
return (
);
})()}
{spotlightCert && (
)}
>
)}
);
}
/* ===== CERT DASHBOARD (legacy name: HomeView) =====
* The per-cert dashboard with stats, Quick Actions, and Domain Overview.
* Now mounted at /cert (view name 'cert-home') — used to be the / landing.
*/
function HomeView({prog,setView,onStart,certMode,onSwitchCert}){
const acc=prog.totalAnswered>0?Math.round((prog.totalCorrect/prog.totalAnswered)*100):0;
const mastered=Object.values(prog.domainStats).filter(s=>s.answered>0&&Math.round((s.correct/s.answered)*100)>=80).length;
const pool=(window.Q||[]).filter(q=>q.exam===certMode);
// Resume target: first unread concept in domain-sorted order for the current cert.
// Falls back to first concept overall if everything is read. Returns null when there
// are no concepts for this cert (in which case the tile is suppressed).
const conceptsForCert=(window.CONCEPTS||[]).filter(c=>(c.cert||'ccna')===certMode);
const readTopics=window.UserData?window.UserData.getReadTopics(certMode):[];
let resumeTarget=null;
let resumeIsComplete=false;
if(conceptsForCert.length){
const sorted=[...conceptsForCert].sort((a,b)=>a.domainId-b.domainId);
const firstUnread=sorted.find(c=>!readTopics.includes(c.topicId));
if(firstUnread){
resumeTarget=firstUnread;
}else{
// Everything read — surface a "review" framing on the same tile
resumeTarget=sorted[0];
resumeIsComplete=true;
}
}
// Enrollment indicator: shows the date the user first enrolled in *any*
// cert (immutable anchor) — for now informational; future free-tier gating
// will read first_cert_enrollment.enrolledAt to decide eligibility.
const certEnroll = window.UserData ? window.UserData.getCertEnrollment() : null;
const certEnrollDate = (certEnroll && certEnroll.enrolledAt)
? new Date(certEnroll.enrolledAt).toLocaleDateString()
: null;
return(
);
}
/* ===== PRACTICE VIEW ===== */
function PracticeView({certMode,prog,onStart,setView}){
const [selDomain,setSelDomain]=React.useState(null);
const pool=(window.Q||[]).filter(q=>q.exam===certMode);
const cliCount=pool.filter(q=>q.type==='cli').length;
const weakCount=prog.missedIds.length;
// Stats moved here from the old HomeView. Practice is the natural home for
// these — readiness signals (accuracy, streak, mastered domains) belong
// next to the session-config they inform.
const acc=prog.totalAnswered>0?Math.round((prog.totalCorrect/prog.totalAnswered)*100):0;
const mastered=Object.values(prog.domainStats).filter(s=>s.answered>0&&Math.round((s.correct/s.answered)*100)>=80).length;
const domainCount=(window.DOMAINS||[]).length || 6;
return(
$configure-session
{pool.filter(q=>q.type!=='cli').length} {window.getExamLabel?window.getExamLabel(certMode):(certMode||'').toUpperCase()} questions · {cliCount} CLI drills in pool
);
}
/* ===== TWEAKS ===== */
function AppTweaks({tweaks,setTweak,certMode,onSwitchCert}){
return(
Currently studying: {(window.getExam&&window.getExam(certMode)?.sub)||certMode||'—'}
setTweak('timerOn',v)}/>
{tweaks.timerOn&&setTweak('timerMins',v)}/>}
setTweak('accent',v)} options={['green','cyan','amber']}/>
setTweak('fontSize',v)} options={['sm','md','lg']}/>
{if(confirm('Reset progress for this cert?')){const cert=(window.UserData&&window.UserData.getCertMode())||'secplus';if(window.UserData)window.UserData.resetProgress(cert,defaultProg());location.reload();}}}/>
);
}
/* ===== ROOT APP ===== */
// Error boundary — without one, ANY exception thrown while rendering a view
// (a malformed question, a partial progress object, a transient state bug)
// unmounts the entire React tree and the user sees a blank black page that
// only a full reload recovers from. This catches the error, keeps it scoped,
// and shows a recoverable card. `resetKey` (the current view) lets the
// boundary auto-clear when the user navigates elsewhere.
class ErrorBoundary extends React.Component{
constructor(props){super(props);this.state={error:null};}
static getDerivedStateFromError(error){return{error};}
componentDidCatch(error,info){try{console.error('[CyberStudy] render error:',error,info);}catch(_){}}
componentDidUpdate(prev){
if(this.state.error && prev.resetKey!==this.props.resetKey){this.setState({error:null});}
}
render(){
if(!this.state.error) return this.props.children;
return(
Something went wrong on this page.
The rest of the app is fine — try again, or reload.
);
}
}
function App(){
// ──── URL-driven view state ────
// The router owns the source of truth (window.location.pathname). React
// state mirrors it so the existing renderView() switch keeps working.
//
// Special pre-mount handling:
// 1. ?reset_token= -> stash token on window, navigate to /reset.
// Backend hands the user a link like /?reset_token=ABC; we capture
// it and route them to the reset form.
// 2. /running and /results: these aren't real routes (no session would
// be available on a reload), so the router doesn't list them.
// They stay in React state only via setView('running'|'results').
const _initialSync = React.useMemo(()=>{
try{
// Step 1: capture reset_token BEFORE the router reads currentView,
// so we can replace the URL with /reset before any subscriber sees it.
const params = new URLSearchParams(window.location.search);
const tok = params.get('reset_token');
if(tok){
window.__resetToken = tok;
params.delete('reset_token');
// Use router.replace so the URL ends at /reset cleanly. The token
// is now in window.__resetToken; ResetPasswordView will read it.
const qs = params.toString();
const newUrl = '/reset' + (qs ? `?${qs}` : '') + (window.location.hash||'');
window.history.replaceState({},'',newUrl);
}
}catch(_){/* malformed URL */}
return window.Router ? window.Router.currentView() : 'home';
},[]);
const[view,_setViewState]=React.useState(_initialSync);
// Wrapped setView: routes that have URLs go through the router; routes
// that don't (running, results) stay in React state.
const ROUTED_VIEWS = React.useMemo(()=>new Set([
'home','learn','practice','progress','notes','final-exam','cli',
'login','signup','forgot','reset','account','career',
'profile','admin-cert-reviews','admin',
'cert-home','certs',
]),[]);
const setView = React.useCallback((next, params)=>{
if(window.Router && ROUTED_VIEWS.has(next)){
window.Router.navigate(next, params);
} else {
_setViewState(next);
}
},[ROUTED_VIEWS]);
// Subscribe to router changes so back/forward + navigate() calls update React.
React.useEffect(()=>{
if(!window.Router) return;
const sync = ()=>_setViewState(window.Router.currentView());
const unsub = window.Router.subscribe(sync);
return unsub;
},[]);
const[certMode,setCertModeState]=React.useState(()=>window.UserData?window.UserData.getCertMode():null);
const[session,setSession]=React.useState(null);
const[finalAnswers,setFinalAnswers]=React.useState(null);
const[prog,setProg]=React.useState(()=>loadProg((window.UserData&&window.UserData.getCertMode())||'secplus'));
// One-shot post-action banners (account deletion, OAuth callback errors).
// Cleared on mount so a page reload doesn't re-show them.
const[flash,setFlash]=React.useState(()=>{
try{
const deleted=sessionStorage.getItem('ccna.flash.account_deleted');
if(deleted){sessionStorage.removeItem('ccna.flash.account_deleted');return{kind:'ok',msg:'Your account has been deactivated. All data will be permanently deleted within 30 days.'};}
const authErr=sessionStorage.getItem('ccna.flash.auth_error');
if(authErr){
sessionStorage.removeItem('ccna.flash.auth_error');
const msg=authErr==='account_deleted'
?'That account has been deleted. Contact whertz0215@gmail.com if you want to restore it.'
:`Sign-in error: ${authErr}`;
return{kind:'err',msg};
}
}catch(_){/* sessionStorage disabled */}
return null;
});
// Re-load progress when cert changes so each track has its own stats
React.useEffect(()=>{ if(certMode) setProg(loadProg(certMode)); },[certMode]);
// ── Phase 4c: gated content loading ───────────────────────────────────────
// Concepts + practice are no longer baked into index.html. They load per-cert
// via window.ContentLoader (which reads /api/access-summary for the entitlement
// level: full → /protected bundle, sample → public sample, redirect_* → none).
// Bump a tick whenever a cert's content finishes loading so child views
// re-read the now-populated window.CONCEPTS / window.Q.
const [, _bumpContent] = React.useState(0);
React.useEffect(()=>{
if(!window.ContentLoader) return;
const onLoaded = ()=> _bumpContent(n=>n+1);
window.addEventListener(window.ContentLoader.EVT, onLoaded);
return ()=> window.removeEventListener(window.ContentLoader.EVT, onLoaded);
},[]);
// Ensure the active cert's content is loaded whenever the cert changes.
React.useEffect(()=>{
if(certMode && window.ContentLoader) window.ContentLoader.ensure(certMode);
},[certMode]);
// Login/logout changes entitlement: drop the cached token+summary and re-ensure
// so a freshly-signed-in user gets full bundles (and logout falls back to samples).
React.useEffect(()=>{
const h=()=>{
if(!window.ContentLoader) return;
window.ContentLoader.resetEntitlement();
const c=(window.UserData&&window.UserData.getCertMode())||certMode;
if(c) window.ContentLoader.ensure(c);
};
window.addEventListener('ccna-user-data-hydrated',h);
return ()=> window.removeEventListener('ccna-user-data-hydrated',h);
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
// Listen for app-wide flash dispatches (any component can fire a
// 'cyberstudy-flash' CustomEvent with {detail:{kind,msg}} to show a banner).
React.useEffect(()=>{
const handler = (ev) => {
if (ev && ev.detail) setFlash({ kind: ev.detail.kind || 'info', msg: ev.detail.msg || '' });
};
window.addEventListener('cyberstudy-flash', handler);
return () => window.removeEventListener('cyberstudy-flash', handler);
}, []);
// Expose a one-shot "switch to this cert + open Learn mode" helper for the
// plan view's Study CTAs. Used by career.jsx's CareerPlan component.
// Must be defined BEFORE any conditional early-return in App, otherwise the
// hook count varies between renders and React throws "rendered more hooks
// than during the previous render" — which manifests as a blank black page.
React.useEffect(() => {
window.__startStudyingCert = (certId) => {
if (!certId) return;
const exam = (window.EXAMS || []).find(e => e.id === certId);
if (!exam) {
try {
window.dispatchEvent(new CustomEvent('cyberstudy-flash', {
detail: { kind: 'info', msg: 'That cert isn\'t studyable in CyberStudy yet — we\'ll let you know when it is.' }
}));
} catch (_) {}
return;
}
// Enroll (sets cert_enrollment + first_cert_enrollment + certMode).
if (window.UserData) window.UserData.enrollInCert(certId, 'career-plan');
setCertModeState(certId);
// Defer setView to a microtask so certMode state has a chance to flush
// before the cert-chooser early-return decides what to render.
Promise.resolve().then(() => {
if (window.AuthNav && window.AuthNav.setView) window.AuthNav.setView('learn');
});
};
return () => { delete window.__startStudyingCert; };
}, []);
// (Removed: home-route redirect effect that bounced enrolled users from /
// to /career//plan on mount + hydration + enrollment changes. The new
// / is an unconditional landing — no auto-redirect.)
// When certMode changes (user picks a new cert from chooser), clear any
// force-chooser flag so future home visits resume normal behavior.
React.useEffect(() => {
if (certMode && window.__forceChooser) {
delete window.__forceChooser;
}
}, [certMode]);
// Sync ?topic= URL param into window.__learnTopic on every nav.
// learn.jsx reads from window.__learnTopic; routing-aware navigation
// (NotesView buttons, future deep links) writes ?topic= on the URL.
React.useEffect(()=>{
if(view!=='learn' || !window.Router) return;
const t = window.Router.currentParams().topic;
if(t) window.__learnTopic = t;
},[view]);
// After login pulls account data down, the cache changes and we need
// to re-read certMode + progress so the UI reflects what's in the
// account rather than what was in localStorage at App-mount time.
React.useEffect(()=>{
const h=()=>{
const c=window.UserData&&window.UserData.getCertMode();
if(c!==undefined) setCertModeState(c||null);
setProg(loadProg(c||certMode||'secplus'));
};
window.addEventListener('ccna-user-data-hydrated',h);
window.addEventListener('ccna-progress-change',h);
return()=>{
window.removeEventListener('ccna-user-data-hydrated',h);
window.removeEventListener('ccna-progress-change',h);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
// Clicking a cert card on the chooser. If it's the cert the user is already
// studying, drop them straight back into the Learn tab where they left off.
// Otherwise route to the per-cert preview page (Enroll / Back) — enrollment
// only happens from there, via pickCert.
function chooseCert(c){
if (c && c === certMode) {
setView('learn');
return;
}
if (c && window.Router && window.Router.navigateCertPreview) {
window.Router.navigateCertPreview(c);
return;
}
pickCert(c);
}
function pickCert(c){
if (c) {
// Picking a cert from the chooser is an enrollment (sets cert_enrollment
// + first_cert_enrollment + certMode). Land on the cert dashboard so
// the user sees their study options.
if (window.UserData) window.UserData.enrollInCert(c, 'chooser');
setCertModeState(c);
setView('cert-home');
} else {
// Clearing: bare setCertMode (so the existing "Switch certification
// track" flow keeps working without nuking first_cert_enrollment).
if (window.UserData) window.UserData.setCertMode(null);
setCertModeState(null);
}
}
const{useTweaks}=window;
const TWEAK_DEFAULTS=/*EDITMODE-BEGIN*/{
"timerOn": false,
"timerMins": 45,
"accent": "green",
"fontSize": "md"
}/*EDITMODE-END*/;
const[tweaks,setTweak]=useTweaks(TWEAK_DEFAULTS);
React.useEffect(()=>{
const root=document.documentElement;
if(tweaks.accent==='cyan'){root.style.setProperty('--green','var(--cyan)');root.style.setProperty('--green-bg','var(--cyan-bg)');}
else if(tweaks.accent==='amber'){root.style.setProperty('--green','var(--amber)');root.style.setProperty('--green-bg','var(--amber-bg)');}
else{root.style.removeProperty('--green');root.style.removeProperty('--green-bg');}
document.body.style.fontSize=tweaks.fontSize==='sm'?'13px':tweaks.fontSize==='lg'?'15px':'14px';
},[tweaks.accent,tweaks.fontSize]);
// Auth subscription — must be declared BEFORE any early return so the
// hook count is constant across renders (Rules of Hooks).
// Also re-reads certMode + view on auth changes so logout reliably
// resets the UI to the cert chooser (since the click handler clears
// certMode in storage but App's React state needs to follow).
const [, _authTick] = React.useState(0);
React.useEffect(()=>{
const u = window.AuthStore && window.AuthStore.subscribe(()=>{
// Re-read cert from storage in case logout cleared it.
const c = window.UserData && window.UserData.getCertMode();
setCertModeState(c || null);
_authTick(t=>t+1);
});
return u;
},[]);
// No cert chosen yet → show selection screen
// Exception: views that render without requiring a cert pick first.
// The chooser is for picking a study track; pages that don't depend on
// any cert (profile, account, admin review queue, auth screens, career
// catalog) should render normally even when certMode is null.
if(!certMode &&
view!=='home' && view!=='certs' && view!=='cert-preview' &&
view!=='login' && view!=='signup' && view!=='forgot' && view!=='reset' &&
view!=='career' && view!=='profile' && view!=='admin-cert-reviews' && view!=='admin' && view!=='account')
return setView('login')} onOpenCareer={()=>setView('career')} certMode={certMode}/>;
function startSession(mode,domainId){
const s=buildSession(mode,domainId,tweaks,certMode);
setSession(s);setFinalAnswers(null);setView('running');
}
// Records a single answered question immediately (so progress saves even if user exits mid-session)
function handleAnswer(q, correct){
setProg(prev => {
const newProg = JSON.parse(JSON.stringify(prev));
// Normalize shape — a progress object loaded from the server or older
// local storage may be missing these fields; dereferencing them below
// would throw inside this updater and blank the whole app.
if (typeof newProg.totalAnswered !== 'number') newProg.totalAnswered = 0;
if (typeof newProg.totalCorrect !== 'number') newProg.totalCorrect = 0;
if (typeof newProg.streak !== 'number') newProg.streak = 0;
if (!newProg.domainStats || typeof newProg.domainStats !== 'object') newProg.domainStats = {};
if (!Array.isArray(newProg.missedIds)) newProg.missedIds = [];
const today = new Date().toDateString();
const yesterday = new Date(Date.now()-86400000).toDateString();
if (newProg.lastStudied !== today) {
newProg.streak = newProg.lastStudied === yesterday ? newProg.streak+1 : 1;
newProg.lastStudied = today;
}
newProg.totalAnswered++;
if (!newProg.domainStats[q.d]) newProg.domainStats[q.d] = {answered:0, correct:0};
newProg.domainStats[q.d].answered++;
if (correct) {
newProg.totalCorrect++;
newProg.domainStats[q.d].correct++;
newProg.missedIds = newProg.missedIds.filter(id => id !== q.id);
} else {
if (!newProg.missedIds.includes(q.id)) newProg.missedIds.push(q.id);
}
saveProg(certMode, newProg);
return newProg;
});
}
function handleComplete(answers){
setProg(prev => { const np = {...prev, sessionCount: (prev.sessionCount||0)+1}; saveProg(certMode, np); return np; });
setFinalAnswers(answers);setView('results');
}
function handleRetry(){
setFinalAnswers(null);setView('running');
setSession(s=>({...s,questions:[...s.questions].sort(()=>Math.random()-0.5)}));
}
function switchCert(){
// Land at /certs so the user sees the chooser. Keep certMode set so the
// chooser highlights the cert they're currently studying (the active-cert
// "✓ Studying" badge); picking another cert switches via pickCert.
window.__forceChooser = true;
setView('certs');
}
const activeTab=view==='running'||view==='results'?'practice':view==='home'?'home':view;
// isLoggedIn re-reads on every render; the auth subscription hook
// (declared above, before the early return) triggers re-renders on
// login/logout so this stays in sync.
const isLoggedIn = !!(window.AuthStore && window.AuthStore.isLoggedIn);
// Gated routes: anonymous users get a friendly "sign in" interstitial
// instead of the actual tab content. Tab nav stays visible so the gate
// is discoverable, not hidden.
const GATED = {
'practice': { name: 'Practice quizzes', desc: 'Sign in to take practice quizzes and track your progress per domain.' },
'running': { name: 'Practice quizzes', desc: 'Sign in to take practice quizzes and track your progress per domain.' },
'results': { name: 'Practice quizzes', desc: 'Sign in to take practice quizzes and track your progress per domain.' },
'progress': { name: 'Progress', desc: 'Sign in to track mastery by domain and see your study history across devices.' },
'final-exam': { name: 'Final Exam', desc: 'Sign in to take the 100-question timed simulation and save your attempt history.' },
};
const renderView=()=>{
// Auth pages — accessible to anyone (no gating); auto-redirect to home
// when already logged in so /signup doesn't hang around post-success.
if(view==='login') {
if(isLoggedIn) { setView('home'); return null; }
return ;
}
if(view==='signup') {
if(isLoggedIn) { setView('home'); return null; }
return ;
}
if(view==='forgot') {
if(isLoggedIn) { setView('home'); return null; }
return ;
}
if(view==='reset') {
// Allow even when logged in — a user might be signed in on one
// device and need to reset from the email link on another, or
// just have a stale session. The reset endpoint changes their
// password regardless.
return ;
}
if(view==='account') {
if(!isLoggedIn) {
return ;
}
return ;
}
if(view==='profile') {
// ProfilePage renders its own "sign in to view" state when logged out;
// we don't gate it with LockedView so the page can show a friendlier CTA.
return window.ProfilePage ? :