1import { getJSON, getURL } from "../api.js"; 2import { navigate } from "../router.js"; 3import { renderAdsense } from "../ui/adsense.js?v=20260917-v352"; 4import { applySeo } from "../ui/seo.js"; 5import { getQuizAttempt, saveQuizAttempt } from "../state.js"; 6import { getPracticePackage, getQuizPracticeMetadata } from "../data/practicePackages.js?v=20260919-v375"; 7 8function esc(s) { 9 return String(s ?? "") 10 .replaceAll("&", "&") 11 .replaceAll("<", "<") 12 .replaceAll(">", ">") 13 .replaceAll('"', """) 14 .replaceAll("'", "'"); 15} 16 17function icon(name) { 18 const body = { 19 quiz: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h.01M11 8h5M8 12h.01M11 12h5M8 16h.01M11 16h3"/>', 20 course: '<path d="M5 4.5h12.5A1.5 1.5 0 0 1 19 6v13H6a2 2 0 0 1-2-2V5.5a1 1 0 0 1 1-1Z"/><path d="M6 16h13M8 8h7"/>', 21 pack: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M7 9h10M7 13h7"/>', 22 clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', 23 repeat: '<path d="M20 7h-9a5 5 0 0 0 0 10h1"/><path d="m17 4 3 3-3 3M4 17h9a5 5 0 0 0 0-10h-1"/><path d="m7 20-3-3 3-3"/>', 24 check: '<path d="m5 12 4 4L19 6"/>', 25 arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>', 26 }[name] || ""; 27 return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`; 28} 29 30function flattenPackageAssessments(pkg) { 31 if (!pkg?.sections?.length) return []; 32 return pkg.sections.flatMap((section, sectionIndex) => 33 (section.assessments || []).map((assessment, assessmentIndex) => ({ 34 ...assessment, 35 sectionTitle: section.title, 36 sectionOrder: sectionIndex + 1, 37 assessmentOrderInSection: assessmentIndex + 1, 38 })) 39 ); 40} 41 42function quizHref(assessment) { 43 return `/q/${assessment.courseId}/${assessment.moduleId}/${assessment.quizId}`; 44} 45 46export async function renderPublicQuiz(root, courseId, moduleId, quizId) { 47 const catalog = await getJSON("/content/catalog.json"); 48 const courseItem = (catalog.courses || []).find((c) => c.id === courseId); 49 if (!courseItem) throw new Error("Course not found"); 50 51 const course = await getJSON(courseItem.path); 52 const modRef = (course.modules || []).find((m) => m.id === moduleId); 53 if (!modRef) throw new Error("Module not found"); 54 55 const mod = await getJSON(modRef.path); 56 const quizMeta = (mod.quizzes || []).find((q) => q.id === quizId); 57 if (!quizMeta) throw new Error("Quiz not found"); 58 59 const practiceMeta = getQuizPracticeMetadata(courseId, moduleId, quizId); 60 const primaryMembership = practiceMeta?.packageMemberships?.[0] || (practiceMeta ? { 61 packageId: practiceMeta.packageId, 62 packageSlug: practiceMeta.packageSlug, 63 packageTitle: practiceMeta.packageTitle, 64 sectionTitle: practiceMeta.sectionTitle, 65 } : null); 66 const primaryPackage = primaryMembership?.packageSlug ? getPracticePackage(primaryMembership.packageSlug) : null; 67 const packageAssessments = flattenPackageAssessments(primaryPackage); 68 const currentDocId = `${courseId}__${moduleId}__${quizId}`; 69 const currentPackageIndex = packageAssessments.findIndex((assessment) => assessment.docId === currentDocId); 70 const currentPackagePosition = currentPackageIndex >= 0 ? currentPackageIndex + 1 : null; 71 const nextPackageAssessment = currentPackageIndex >= 0 && currentPackageIndex + 1 < packageAssessments.length 72 ? packageAssessments[currentPackageIndex + 1] 73 : null; 74 const packagePassedCount = packageAssessments.filter((assessment) => getQuizAttempt(assessment.courseId, assessment.moduleId, assessment.quizId)?.pass).length; 75 const packageProgressPercent = packageAssessments.length ? Math.round((packagePassedCount / packageAssessments.length) * 100) : 0; 76 const packageDisplayTitle = primaryPackage?.shortTitle || primaryMembership?.packageTitle || practiceMeta?.packageTitle || ""; 77 const packageUrl = primaryMembership?.packageSlug ? `/skill-assessment/${primaryMembership.packageSlug}` : ""; 78 const quizTitle = practiceMeta?.displayTitle || quizMeta.title || quizMeta.id; 79 const questions = await getJSON(quizMeta.path); 80 const total = Array.isArray(questions) ? questions.length : 0; 81 const moduleTitle = modRef.title || moduleId; 82 const canonicalPath = `/q/${courseId}/${moduleId}/${quizId}`; 83 const description = `Free practice quiz: ${quizTitle}. Course: ${course.title}. Module: ${moduleTitle}. ${total} questions. Instant score report.`; 84 const image = course.cover 85 ? String(course.cover).startsWith("http") 86 ? String(course.cover)
87 : getURL(course.cover) 88 : getURL(`/content/images/${courseId}.jpg`); 89 90 applySeo({ 91 id: `quiz:${courseId}:${moduleId}:${quizId}`, 92 title: `${quizTitle} | Free Practice Quiz | ${course.title}`, 93 description, 94 canonicalPath, 95 image, 96 type: "website", 97 noindex: true, 98 }); 99 100 let idx = 0; 101 const answers = {}; 102 let submitted = false; 103 104 const courseCategory = course.categoryId || course.category || ""; 105 const relatedCourses = (catalog.courses || []) 106 .filter((c) => c.id !== courseId && (c.categoryId === courseCategory || c.category === courseCategory)) 107 .slice(0, 2); 108 109 const relatedCoursesHTML = relatedCourses.length 110 ? ` 111 <section class="quizRelatedCoursesV335"> 112 <div class="quizAsideEyebrowV335">RELATED COURSES</div> 113 <div class="quizRelatedCourseListV335"> 114 ${relatedCourses 115 .map( 116 (c) => ` 117 <button type="button" class="quizRelatedCourseV335" data-nav="/course/${esc(c.id)}"> 118 <span class="quizRelatedCourseImageV335"> 119 <img src="${esc(getURL(`/content/images/${c.id}.jpg`))}" alt="" onerror="this.style.display='none'" /> 120 </span> 121 <span> 122 <strong>${esc(c.title)}</strong> 123 <small>Explore course ${icon("arrow")}</small> 124 </span> 125 </button> 126 ` 127 ) 128 .join("")} 129 </div> 130 </section> 131 ` 132 : ""; 133 134 const packageRailHTML = primaryPackage && packageUrl 135 ? ` 136 <section class="quizPackageRailV369"> 137 <div class="quizAsideEyebrowV335">YOUR SKILL ASSESSMENT PACK</div> 138 <a class="quizPackageRailTitleV369" href="${esc(packageUrl)}" data-link> 139 <span>${icon("pack")}</span> 140 <strong>${esc(packageDisplayTitle)}</strong> 141 ${icon("arrow")} 142 </a> 143 <p class="quizPackageRailContextV369">${currentPackagePosition ? `Assessment ${currentPackagePosition} of ${packageAssessments.length}` : `${packageAssessments.length} assessments`}${primaryMembership?.sectionTitle ? ` ⢠${esc(primaryMembership.sectionTitle)}` : ""}</p> 144 <div class="quizPackageProgressV369" data-package-progress-track aria-label="${packageProgressPercent}% of assessment pack completed"><i data-package-progress-bar style="width:${packageProgressPercent}%"></i></div> 145 <div class="quizPackageProgressMetaV369"><span><strong data-package-passed-count>${packagePassedCount}</strong> of ${packageAssessments.length} passed</span><strong data-package-progress-percent>${packageProgressPercent}%</strong></div> 146 <button type="button" class="quizBtnV335 primary quizPackageOpenBtnV369" data-open-package>View full skill assessment ${icon("arrow")}</button> 147 </section> 148 ` 149 : ""; 150 151 root.innerHTML = ` 152 <div class="quizPublicV335"> 153 <section class="quizPublicHeroV335"> 154 <div class="quizPublicHeroInnerV335"> 155 <div class="quizPublicHeroCopyV335"> 156 ${packageUrl ? `<nav class="quizPackageBreadcrumbV369" aria-label="Breadcrumb"><a href="/skill-assessment" data-link>Skill Assessments</a><span>âº</span><a href="${esc(packageUrl)}" data-link>${esc(packageDisplayTitle)}</a><span>âº</span><span>Current assessment</span></nav>` : ""} 157 <div class="quizEyebrowV335">${practiceMeta ? esc(practiceMeta.contentType || "SKILL ASSESSMENT") : "FREE PRACTICE QUIZ"}</div> 158 <h1>${esc(quizTitle)}</h1> 159 ${packageUrl ? `<a class="quizPackageConnectionV369" href="${esc(packageUrl)}" data-link><span class="quizPackageConnectionIconV369">${icon("pack")}</span><span><small>Included in Skill Assessment Pack</small><strong>${esc(packageDisplayTitle)}</strong></span>${icon("arrow")}</a>` : ""} 160 ${practiceMeta?.packageMemberships?.length > 1 ? `<div class="quizPackageLinksV356">${practiceMeta.packageMemberships.slice(1).map((membership) => `<a class="quizPackageLinkV353" href="/skill-assessment/${esc(membership.packageSlug)}" data-link>Also included in ${esc(membership.packageTitle)}</a>`).join("")}</div>` : ""} 161 <p>${esc(course.title)} <span>â¢</span> ${esc(moduleTitle)}</p> 162 <div class="quizPublicFactsV335">
163 <span>${icon("quiz")} ${total} questions</span> 164 <span>${icon("check")} Instant results</span> 165 <span>${icon("repeat")} Retake anytime</span> 166 </div> 167 </div> 168 <div class="quizPublicHeroActionsV335"> 169 <button id="quizJumpStartBtn" class="quizBtnV335 primary" type="button">Start assessment ${icon("arrow")}</button> 170 ${packageUrl ? `<button id="openPackageBtn" class="quizBtnV335 secondary" type="button">${icon("pack")} View skill assessment pack</button>` : ""} 171 <button id="openCourseBtn" class="quizBtnV335 secondary" type="button">${icon("course")} View course</button> 172 </div> 173 </div> 174 </section> 175 176 <section class="quizPublicBodyV335"> 177 <div class="quizPublicLayoutV335" id="pubQuizGrid"> 178 <main class="quizPublicMainV335"> 179 <section class="quizQuestionCardV335 quizPublicQuestionCardV335"> 180 <div class="quizQuestionCountV335">Question <span id="qNum"></span> of ${total}</div> 181 <h2 id="qText" class="quizQuestionTextV335"></h2> 182 <div id="opts" class="quizOptionsV335"></div> 183 184 <div class="quizQuestionNavV335"> 185 <button id="prevBtn" class="quizBtnV335 secondary" type="button">â Previous</button> 186 <button id="nextBtn" class="quizBtnV335 secondary" type="button">Next â</button> 187 </div> 188 189 <div class="quizSubmitStackV335"> 190 <button id="submitBtn" class="quizBtnV335 primary" type="button">Submit quiz</button> 191 <button id="retryBtn" class="quizBtnV335 secondary" type="button" style="display:none">Retake quiz</button> 192 </div> 193 194 <div id="resultBox" class="quizResultBoxV335" style="display:none"></div> 195 <div id="reportBox" class="quizReportBoxV335" style="display:none"></div> 196 </section> 197 </main> 198 199 <aside class="quizPublicAsideV335"> 200 ${packageRailHTML} 201 <section class="quizAdCardV335"> 202 <div class="quizAsideEyebrowV335">ADVERTISEMENT</div> 203 <div id="adSide1"></div> 204 </section> 205 ${relatedCoursesHTML} 206 </aside> 207 </div> 208 </section> 209 </div> 210 `; 211 212 root.querySelector("#openCourseBtn").onclick = () => navigate(`/course/${courseId}`); 213 root.querySelector("#openPackageBtn")?.addEventListener("click", () => { if (packageUrl) navigate(packageUrl); }); 214 root.querySelector("[data-open-package]")?.addEventListener("click", () => { if (packageUrl) navigate(packageUrl); }); 215 const jumpBtn = root.querySelector("#quizJumpStartBtn"); 216 if (jumpBtn) jumpBtn.onclick = () => root.querySelector("#pubQuizGrid")?.scrollIntoView({ behavior: "smooth", block: "start" }); 217 218 renderAdsense(root.querySelector("#adSide1"), { slot: "5606596534", width: 300, height: 250, minHeight: 250 }); 219 220 root.querySelectorAll("[data-nav]").forEach((el) => { 221 el.addEventListener("click", () => navigate(el.getAttribute("data-nav"))); 222 }); 223 224 const qNumEl = root.querySelector("#qNum"); 225 const qTextEl = root.querySelector("#qText"); 226 const optsEl = root.querySelector("#opts"); 227 const resultBox = root.querySelector("#resultBox"); 228 const reportBox = root.querySelector("#reportBox"); 229 const prevBtn = root.querySelector("#prevBtn"); 230 const nextBtn = root.querySelector("#nextBtn"); 231 const submitBtn = root.querySelector("#submitBtn"); 232 const retryBtn = root.querySelector("#retryBtn"); 233 234 function lockInputs() { 235 optsEl.querySelectorAll("input").forEach((inp) => (inp.disabled = true)); 236 optsEl.querySelectorAll(".q-opt-label").forEach((label) => (label.style.pointerEvents = "none")); 237 } 238 239 function unlockInputs() { 240 optsEl.querySelectorAll("input").forEach((inp) => (inp.disabled = false)); 241 optsEl.querySelectorAll(".q-opt-label").forEach((label) => (label.style.pointerEvents = "auto")); 242 } 243 244 function renderQuestion() { 245 if (!Array.isArray(questions) || !questions.length) return; 246 const q = questions[idx]; 247 qNumEl.textContent = String(idx + 1); 248 qTextEl.textContent = String(q.question || ""); 249 const correctIdx = Number(q.correctAnswer); 250 251 optsEl.innerHTML = (q.options || []) 252 .map((opt, i) => { 253 const picked = answers[idx];
254 const isPicked = picked === i; 255 const isCorrect = i === correctIdx; 256 let stateClass = ""; 257 let badge = ""; 258 if (submitted) { 259 if (isCorrect) { 260 stateClass = "is-correct"; 261 badge = `<span class="quizAnswerBadgeV335 correct">Correct</span>`; 262 } else if (isPicked) { 263 stateClass = "is-wrong"; 264 badge = `<span class="quizAnswerBadgeV335 wrong">Your answer</span>`; 265 } 266 } 267 268 return ` 269 <label class="q-opt-label ${stateClass}"> 270 <input type="radio" name="opt" value="${i}" ${isPicked ? "checked" : ""} ${submitted ? "disabled" : ""} /> 271 <span class="quizOptionTextV335">${esc(opt)}</span> 272 ${badge} 273 </label> 274 `; 275 }) 276 .join(""); 277 278 if (!submitted) { 279 optsEl.querySelectorAll("input").forEach((inp) => { 280 inp.onchange = () => { 281 answers[idx] = Number(inp.value); 282 }; 283 }); 284 unlockInputs(); 285 } else { 286 lockInputs(); 287 } 288 289 prevBtn.disabled = idx === 0; 290 nextBtn.disabled = idx === questions.length - 1; 291 } 292 293 function buildReport() { 294 const totalQ = questions.length; 295 let correct = 0; 296 const items = questions.map((q, i) => { 297 const correctIdx = Number(q.correctAnswer); 298 const pickedIdx = typeof answers[i] === "number" ? answers[i] : null; 299 const ok = pickedIdx === correctIdx; 300 if (ok) correct += 1; 301 return { 302 i, 303 question: q.question || "", 304 options: Array.isArray(q.options) ? q.options : [], 305 correctIdx, 306 pickedIdx, 307 explanation: q.explanation || "", 308 ok, 309 }; 310 }); 311 const score = Math.round((correct / totalQ) * 100); 312 const pass = score >= 70; 313 return { correct, total: totalQ, score, pass, items }; 314 } 315 316 function renderReport(report) { 317 const currentUrl = encodeURIComponent(window.location.href); 318 const shareText = encodeURIComponent(`I just scored ${report.score}% on the ${quizTitle} practice quiz on ITlearn360! Can you beat my score?`); 319 const twitterUrl = `https://twitter.com/intent/tweet?text=${shareText}&url=${currentUrl}`; 320 const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${currentUrl}`; 321 const upsellMessage = practiceMeta 322 ? report.pass 323 ? nextPackageAssessment 324 ? `You passed this assessment. Continue with the next assessment in ${packageDisplayTitle}.` 325 : `You passed the final assessment in ${packageDisplayTitle}. Review your full pack progress and completion rewards.` 326 : `Review the answers below, then return to ${packageDisplayTitle} when you are ready to continue.` 327 : report.pass 328 ? "Great job. Continue into the full course to deepen the skill and work through the complete learning path." 329 : "Review the gaps below, then use the full course to strengthen the topics that need more work."; 330 331 resultBox.style.display = "block"; 332 resultBox.innerHTML = ` 333 <section class="quizScoreCardV335 ${report.pass ? "pass" : "retry"}"> 334 <div class="quizScoreLabelV335">${report.pass ? "Passed" : "Keep going"}</div> 335 <strong>${report.score}%</strong> 336 <p>${report.correct} of ${report.total} questions correct</p> 337 <div class="quizScoreCtaV335"> 338 <p>${upsellMessage}</p> 339 <div> 340 ${practiceMeta ? `${nextPackageAssessment && report.pass ? `<button type="button" class="quizBtnV335 primary" data-next-package-assessment>Next assessment ${icon("arrow")}</button>` : ""}<button type="button" class="quizBtnV335 secondary" data-back-package>View full skill assessment pack</button>` : `<button type="button" class="quizBtnV335 primary" data-enroll-course>View full course</button>`} 341 <a href="${twitterUrl}" target="_blank" rel="noopener noreferrer" class="quizShareBtnV335 x">Share on X</a> 342 <a href="${linkedinUrl}" target="_blank" rel="noopener noreferrer" class="quizShareBtnV335 linkedin">Share on LinkedIn</a> 343 </div> 344 </div> 345 </section> 346 `; 347 resultBox.querySelector("[data-enroll-course]")?.addEventListener("click", () => navigate(`/course/${courseId}`)); 348 resultBox.querySelector("[data-back-package]")?.addEventListener("click", () => { if (packageUrl) navigate(packageUrl); }); 349 resultBox.querySelector("[data-next-package-assessment]")?.addEventListener("click", () => { if (nextPackageAssessment) navigate(quizHref(nextPackageAssessment)); }); 350 351 reportBox.style.display = "block"; 352 reportBox.innerHTML = ` 353 <section class="quizDetailedReportV335"> 354 <div class="quizDetailedReportHeadV335"> 355 <div> 356 <div class="quizEyebrowV335">DETAILED REPORT</div> 357 <h3>Review each answer.</h3> 358 </div>
359 <span>${report.correct}/${report.total} correct</span> 360 </div> 361 <div class="quizReportItemsV335"> 362 ${report.items 363 .map((it) => { 364 const yourAns = typeof it.pickedIdx === "number" ? it.options[it.pickedIdx] : "Not answered"; 365 const correctAns = typeof it.correctIdx === "number" ? it.options[it.correctIdx] : ""; 366 return ` 367 <article class="quizReportItemV335 ${it.ok ? "correct" : "wrong"}"> 368 <div class="quizReportItemHeadV335"> 369 <span>${it.ok ? "CORRECT" : "REVIEW"}</span> 370 <strong>${esc(it.question)}</strong> 371 </div> 372 <p class="quizYourAnswerV335">Your answer: ${esc(yourAns)}</p> 373 ${!it.ok ? `<p class="quizCorrectAnswerV335">Correct answer: ${esc(correctAns)}</p>` : ""} 374 ${it.explanation ? `<div class="quizExplanationV335"><b>Explanation:</b> ${esc(it.explanation)}</div>` : ""} 375 </article> 376 `; 377 }) 378 .join("")} 379 </div> 380 </section> 381 `; 382 } 383 384 function refreshPackageProgress() { 385 if (!packageAssessments.length) return; 386 const passed = packageAssessments.filter((assessment) => getQuizAttempt(assessment.courseId, assessment.moduleId, assessment.quizId)?.pass).length; 387 const percent = Math.round((passed / packageAssessments.length) * 100); 388 const bar = root.querySelector("[data-package-progress-bar]"); 389 const track = root.querySelector("[data-package-progress-track]"); 390 const count = root.querySelector("[data-package-passed-count]"); 391 const percentEl = root.querySelector("[data-package-progress-percent]"); 392 if (bar) bar.style.width = `${percent}%`; 393 if (track) track.setAttribute("aria-label", `${percent}% of assessment pack completed`); 394 if (count) count.textContent = String(passed); 395 if (percentEl) percentEl.textContent = `${percent}%`; 396 } 397 398 function submit() { 399 if (!Array.isArray(questions) || !questions.length) return; 400 submitted = true; 401 const report = buildReport(); 402 saveQuizAttempt(courseId, moduleId, quizId, { 403 score: report.score, 404 pass: report.pass, 405 correct: report.correct, 406 total: report.total, 407 submittedAt: new Date().toISOString(), 408 packageId: practiceMeta?.packageId || null, 409 }); 410 refreshPackageProgress(); 411 submitBtn.disabled = true; 412 submitBtn.style.display = "none"; 413 retryBtn.style.display = "inline-flex"; 414 renderQuestion(); 415 renderReport(report); 416 resultBox.scrollIntoView({ behavior: "smooth", block: "center" }); 417 } 418 419 function retry() { 420 idx = 0; 421 submitted = false; 422 Object.keys(answers).forEach((k) => delete answers[k]); 423 submitBtn.disabled = false; 424 submitBtn.style.display = "inline-flex"; 425 retryBtn.style.display = "none"; 426 resultBox.style.display = "none"; 427 reportBox.style.display = "none"; 428 resultBox.innerHTML = ""; 429 reportBox.innerHTML = ""; 430 renderQuestion(); 431 root.querySelector("#pubQuizGrid")?.scrollIntoView({ behavior: "smooth", block: "start" }); 432 } 433 434 prevBtn.onclick = () => { 435 if (idx > 0) { 436 idx -= 1; 437 renderQuestion(); 438 } 439 }; 440 nextBtn.onclick = () => { 441 if (idx < questions.length - 1) { 442 idx += 1; 443 renderQuestion(); 444 } 445 }; 446 submitBtn.onclick = submit; 447 retryBtn.onclick = retry; 448 449 renderQuestion(); 450}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.