/* 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.

{!isLoggedIn && ( )} {tellUsStatus && (
{tellUsStatus==='sending' ? 'Sending…' : tellUsMsg}
)}
{exams.map(e => { const qCount = (window.Q||[]).filter(q=>q.exam===e.id && q.type!=='cli').length; const domainCount = (window.getDomainsForExam ? window.getDomainsForExam(e.id) : ((window.DOMAINS_BY_EXAM||{})[e.id]||[])).length; const onPath = onPathCertIds.has(e.id); const isActive = certMode === e.id; return ( ); })} {underDev.length>0 &&
Coming soon
} {underDev.map(({id, meta}) => (
{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 (

That certification isn’t available.

); } const domains = window.getDomainsForExam ? window.getDomainsForExam(certId) : ((window.DOMAINS_BY_EXAM||{})[certId]||[]); const qCount = (window.Q||[]).filter(q=>q.exam===certId && q.type!=='cli').length; const conceptCount = (window.CONCEPTS||[]).filter(c=>c.cert===certId).length; return (
{CERT_ICONS[certId]||"📋"}

{exam.label}

{meta.examCode || exam.sub}

{meta.blurb || exam.description}

{qCount>0?qCount:"—"}Practice questions
{conceptCount>0?conceptCount:"—"}Concept pages
{domains.length||"—"}Exam domains
{domains.length>0 && (

Exam domains

    {domains.map(d=>(
  • {d.name} {typeof d.weight==='number' && {d.weight}%}
  • ))}
)} {(meta.format || meta.passing) && (
{meta.format &&
Format{meta.format}
} {meta.passing &&
Passing score{meta.passing}
} {meta.recertifyEvery &&
Recertifyevery {meta.recertifyEvery}
}
)}
{!isLoggedIn && (

You can study as a guest. to save progress across devices.

)}
); } /* ===== NOTES VIEW ===== */ function NotesView({setView,setLearnTopic}){ const notes=window.useNotes?window.useNotes():[]; const byTopic={}; notes.forEach(n=>{(byTopic[n.topicId]=byTopic[n.topicId]||{title:n.topicTitle,items:[]}).items.push(n);}); const topicIds=Object.keys(byTopic); return(
$ show notes --all
{notes.length} note{notes.length===1?'':'s'} across {topicIds.length} topic{topicIds.length===1?'':'s'} — click any topic to jump back
{notes.length===0 ?
No notes yet. Open Learn Mode, highlight a passage, click “✎ Add note.”
:topicIds.map(tid=>{const grp=byTopic[tid];return(
{setLearnTopic&&setLearnTopic(tid);setView('learn',{topic:tid});}}>{grp.title} {grp.items.length} note{grp.items.length===1?'':'s'}
{grp.items.map(n=>(
“{n.selectedText}”
{n.note}
{new Date(n.createdAt).toLocaleDateString()}{n.updatedAt?' · edited':''}
))}
);}) }
); } /* ===== 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(
$ show dashboard
{pool.filter(q=>q.type!=='cli').length} {window.getExamLabel?window.getExamLabel(certMode):(certMode||'').toUpperCase()} questions loaded · {(window.DOMAINS||[]).length} domains {certEnrollDate && · enrolled {certEnrollDate}}
Total Answered
{prog.totalAnswered||'—'}
Accuracy
=70?'green':'amber'}`}>{prog.totalAnswered>0?`${acc}%`:'—'}
Streak
{prog.streak>0?`${prog.streak}d`:'—'}
Domains ≥80%
{mastered}/6
Quick Actions
{pool.some(q=>q.type==='cli') ? :resumeTarget&&}
Domain Overview
{(window.DOMAINS||[]).map(d=>{const s=prog.domainStats[d.id]||{answered:0,correct:0};const qs=pool.filter(q=>q.d===d.id&&q.type!=='cli').length;const progressPct=qs>0?Math.min(100,Math.round((s.answered/qs)*100)):0;return( );})}
{onSwitchCert&&(
)}
); } /* ===== 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
Total Answered
{prog.totalAnswered||'—'}
Accuracy
=70?'green':'amber'}`}>{prog.totalAnswered>0?`${acc}%`:'—'}
Streak
{prog.streak>0?`${prog.streak}d`:'—'}
Domains ≥80%
{mastered}/{domainCount}
Session Type
{cliCount>0&&}
Domain Focus
{(window.DOMAINS||[]).map(d=>{ const qs=pool.filter(q=>q.d===d.id&&q.type!=='cli').length; const cqs=pool.filter(q=>q.d===d.id&&q.type==='cli').length; const s=prog.domainStats[d.id]||{answered:0,correct:0}; const progressPct = qs>0 ? Math.min(100, Math.round((s.answered/qs)*100)) : 0; const isSel=selDomain===d.id; return( ); })}
{selDomain&&(
)}
); } /* ===== PROGRESS VIEW ===== */ function ProgressView({prog,onStart}){ const acc=prog.totalAnswered>0?Math.round((prog.totalCorrect/prog.totalAnswered)*100):0; const missed=(window.Q||[]).filter(q=>prog.missedIds.includes(q.id)); return(
$ show progress --all
Session #{prog.sessionCount} · Last studied: {prog.lastStudied||'never'}
Domain Mastery
{(window.DOMAINS||[]).map(d=>{const s=prog.domainStats[d.id]||{answered:0,correct:0};const pct=s.answered>0?Math.round((s.correct/s.answered)*100):0;return(
{d.id} {d.name}
{s.answered>0?`${pct}% (${s.answered}q)`:'—'}
);})}
Total Answered
{prog.totalAnswered}
Overall Accuracy
=70?'green':'amber'}`}>{prog.totalAnswered>0?`${acc}%`:'—'}
Study Streak
{prog.streak>0?`${prog.streak}d`:'—'}
Need Review
{prog.missedIds.length}
Missed Questions ({missed.length})
{missed.length===0?
No missed questions yet!
:
{missed.map(q=>(
onStart('domain',q.d)}> D{q.d} {q.stem.slice(0,80)}{q.stem.length>80?'…':''}
))}
} {missed.length>0&&}
); } /* ===== 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 ? :
Profile is loading…
; } if(view==='admin-cert-reviews') { return window.AdminCertReviewsPage ? :
Admin page is loading…
; } if(view==='admin') { // V1 admin panel (dashboard / users / requests / audit log). The // AdminPanel component dispatches to the right sub-page based on the // URL. Server endpoints enforce admin-only — client check inside // AdminPanel is UX-only. return window.AdminPanel ? :
Admin panel is loading…
; } if(!isLoggedIn && GATED[view]) { return ; } // 4c concept gate: Learn is no longer universally free. When the active // cert's concepts require sign-in/verification (per /api/access-summary), // show the locked interstitial instead of an empty Learn page. "full" and // "sample" fall through and render normally (the loader populates CONCEPTS). if(view==='learn' && certMode && window.ContentLoader && window.ContentLoader.access){ const cLvl=(window.ContentLoader.access[certMode]||{}).concepts; if(cLvl==='redirect_login') return ; if(cLvl==='redirect_verify') return ; } // / is an unconditional landing — same for every user state. Two CTAs // (Careers / Certifications) plus a progress widget when applicable. if(view==='home') return ; // /cert — per-cert dashboard (stats / Quick Actions / Domain Overview). // If somehow reached without a cert, bounce to the chooser. if(view==='cert-home') { if(!certMode) return setView('login')} onOpenCareer={()=>setView('career')} certMode={certMode}/>; return ; } // /certs — cert chooser as a regular view (TopBar + footer stay visible). if(view==='certs') return setView('login')} onOpenCareer={()=>setView('career')} certMode={certMode}/>; // /certs/ — per-cert preview. Enroll → pickCert (enrolls + cert-home); // Back → chooser. If the previewed cert is somehow the active one, resume Learn. if(view==='cert-preview') { const pid = window.Router && window.Router.currentPreviewCert ? window.Router.currentPreviewCert() : null; return { if(id===certMode) setView('learn'); else pickCert(id); }} onBack={()=>setView('certs')} onSignInClick={()=>setView('login')}/>; } if(view==='practice') return ; if(view==='running'&&session) return setView('practice')}/>; if(view==='results'&&session&&finalAnswers) return setView(certMode?'cert-home':'home')} onRetry={handleRetry}/>; if(view==='learn') return setView(certMode?'cert-home':'home')} certMode={certMode}/>; if(view==='notes') return {window.__learnTopic=t;}}/>; if(view==='progress') return ; if(view==='final-exam') return setView(certMode?'cert-home':'home')}/>; // /career URL still works — alias for home. Renders the same CareerPlanner // (which internally routes to detail/plan/compare via currentCareerRoute). if(view==='career') return window.CareerPlanner ? setView('home')}/> :
Loading…
; return window.CareerPlanner ? setView('home')}/> :
Loading…
; }; // Expose setView so auth.jsx components can navigate without prop-drilling window.AuthNav = { setView, switchCert }; return(
{flash && (
{flash.msg}
)}
{renderView()}
); } ReactDOM.createRoot(document.getElementById('root')).render();