PageSourceSearch

https://expertle.ai/js/templates/template_gear_check.js

js expertle.ai collected 2026-10-05 15:16:44 UTC 14,790 bytes, 247 lines download raw bytes

1import { getThemeVars } from './theme_vars.js';
2
3// ═══════════════════════════════════════════════════════════════
4// EXPERTLE — Gear Check Template
5// PPE selection game with timer. Task cards flash, pick the right gear.
6// JSON in → HTML out. Dark course theme.
7// ═══════════════════════════════════════════════════════════════
8
9export function wrapGearCheckData(sectionJson, options = {}) {
10  const theme = options.theme || 'dark';
11  const d = sectionJson;
12  const roundsJson = JSON.stringify(d.rounds || []).replace(/</g, '\\x3c');
13  const ppeJson = JSON.stringify(d.ppe_items || []).replace(/</g, '\\x3c');
14
15  return `<!DOCTYPE html>
16<html lang="en-AU">
17<head>
18  <meta charset="UTF-8">
19  <meta name="viewport" content="width=device-width, initial-scale=1.0">
20  <title>${esc(d.title)} — ${esc(d.unitCode || '')}</title>
21  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
22  <style>${GAME_CSS(theme)}
23.ppe-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin: 20px 0; }
24.ppe-item { background: var(--bg-card); border: 2px solid var(--border-default); border-radius: 10px; padding: 14px 10px; text-align: center; cursor: pointer; transition: all 0.2s; user-select: none; }
25.ppe-item:hover { border-color: var(--accent-primary); background: var(--bg-card-hover); }
26.ppe-item.selected { border-color: var(--accent-secondary); background: rgba(59,130,246,0.15); }
27.ppe-item.correct { border-color: var(--accent-success); background: rgba(34,197,94,0.15); }
28.ppe-item.missed { border-color: var(--accent-warning); background: rgba(249,115,22,0.15); }
29.ppe-item.extra { border-color: var(--text-muted); background: rgba(107,115,148,0.1); }
30.ppe-icon { font-size: 2rem; margin-bottom: 6px; }
31.ppe-label { font-size: 0.8rem; font-weight: 600; }
32.task-card { background: var(--bg-card); border: 1px solid var(--accent-primary); border-radius: 12px; padding: 20px; text-align: center; margin-bottom: 16px; }
33.task-card h3 { color: var(--accent-primary); margin-bottom: 8px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; }
34.task-card p { font-size: 1.1rem; font-weight: 600; }
35.timer-bar { width: 100%; height: 8px; background: var(--bg-card); border-radius: 4px; margin-bottom: 20px; overflow: hidden; }
36.timer-fill { height: 100%; background: var(--accent-primary); border-radius: 4px; transition: width 1s linear; }
37.timer-fill.urgent { background: var(--accent-error); }
38.round-feedback { margin-top: 16px; }
39.feedback-item { background: var(--bg-card); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; font-size: 0.85rem; }
40.feedback-item.missed-fb { border-left: 3px solid var(--accent-warning); }
41.feedback-item.extra-fb { border-left: 3px solid var(--text-muted); }
42  </style>
43</head>
44<body>
45  <div class="section-wrapper">
46    <header class="section-header">
47      <div class="section-badge">Gear Check</div>
48      <h1 class="section-title">${esc(d.title)}</h1>
49      <p class="section-instruction">${esc(d.description || 'Select the correct PPE for each task')}</p>
50    </header>
51    <div class="timer-bar"><div class="timer-fill" id="timer-fill" style="width:100%"></div></div>
52    <main class="game-area">
53      <div class="task-card" id="task-card"><h3>Round <span id="round-num">1</span> of <span id="round-total">?</span></h3><p id="task-text">Loading...</p></div>
54      <div class="ppe-grid" id="ppe-grid"></div>
55      <div style="text-align:center;"><button class="btn btn-primary" id="lock-in-btn" disabled>Lock In</button></div>
56      <div class="round-feedback hidden" id="round-feedback"></div>
57      <div class="hidden" id="next-round-wrap" style="text-align:center; margin-top:16px;"><button class="btn btn-primary" id="next-round-btn">Next Round →</button></div>
58    </main>
59    <footer class="section-footer">
60      <div class="score-display"><span class="score-label">Score</span><span class="score-value" id="score-value">0</span><span class="score-max">/ 100</span></div>
61      <button class="btn-complete" id="btn-complete" disabled>Complete Section</button>
62    </footer>
63  </div>
64  <script>
65const PPE_ITEMS = ${ppeJson};
66const ROUNDS = ${roundsJson};
67const TIME_PER_ROUND = ${d.time_per_round_seconds || 15};
68const SECTION_DATA = { sectionId: "${esc(d.sectionId || '')}", score: 0, maxScore: 100, completed: false, startTime: Date.now() };
69setInterval(() => { window.parent.postMessage({ type:'progressSnapshot', xp:0, score:SECTION_DATA.score||0 }, '*'); }, 10000);
70
71const PPE_ICONS = { helmet:'🪖', glasses:'🥽', earmuffs:'🎧', boots:'🥾', vest:'🦺', gloves:'🧤', gloves_chem:'🧤', mask:'😷', shield:'🛡️', harness:'🪢' };
72
73let currentRound = 0;
74let selected = new Set();
75let timerInterval = null;
76
77document.getElementById('round-total').textContent = ROUNDS.length;
78
79function startRound() {
80  if (currentRound >= ROUNDS.length) { endGame(); return; }
81  const round = ROUNDS[currentRound];
82  selected = new Set();
83  document.getElementById('round-num').textContent = currentRound + 1;
84  document.getElementById('task-text').textContent = round.task;
85  document.getElementById('round-feedback').classList.add('hidden');
86  document.getElementById('round-feedback').innerHTML = '';
87  document.getElementById('next-round-wrap').classList.add('hidden');
88  document.getElementById('lock-in-btn').disabled = false;
89
90  const grid = document.getElementById('ppe-grid');
91  grid.innerHTML = PPE_ITEMS.map(p => '<div class="ppe-item" data-id="' + p.id + '" tabindex="0" role="button" aria-label="' + p.label + '">' + (p.image_url ? '<img src="' + p.image_url + '" style="width:40px;height:40px;object-fit:cover;border-radius:6px;" alt="' + p.label + '">' : '<div class="ppe-icon">' + (PPE_ICONS[p.icon] || '⚙️') + '</div>') + '<div class="ppe-label">' + p.label + '</div></div>').join('');
92  grid.querySelectorAll('.ppe-item').forEach(el => {
93    el.addEventListener('click', () => togglePpe(el));
94    el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePpe(el); } });
95  });
96
97  startTimer();
98}
99
100function togglePpe(el) {
101  const id = el.dataset.id;
102  if (selected.has(id)) { selected.delete(id); el.classList.remove('selected'); }
103  else { selected.add(id); el.classList.add('selected'); }
104}
105
106function startTimer() {
107  const fill = document.getElementById('timer-fill');
108  fill.style.width = '100%';
109  fill.classList.remove('urgent');
110  let remaining = TIME_PER_ROUND;
111  clearInterval(timerInterval);
112  timerInterval = setInterval(() => {
113    remaining--;
114    fill.style.width = (remaining / TIME_PER_ROUND * 100) + '%';
115    if (remaining <= 3) fill.classList.add('urgent');
116    if (remaining <= 0) { clearInterval(timerInterval); checkRound(); }
117  }, 1000);
118}
119
120document.getElementById('lock-in-btn').addEventListener('click', () => { clearInterval(timerInterval); checkRound(); });
121
122function checkRound() {
123  document.getElementById('lock-in-btn').disabled = true;
124  const round = ROUNDS[currentRound];
125  const required = new Set(round.required_ppe);
126  let correct = 0, missed = [], extras = [];
127
128  document.querySelectorAll('.ppe-item').forEach(el => {
129    const id = el.dataset.id;
130    const wasSelected = selected.has(id);
131    const isRequired = required.has(id);
132    if (wasSelected && isRequired) { el.classList.add('correct'); correct++; window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:true }, '*'); }
133    else if (!wasSelected && isRequired) { el.classList.add('missed'); missed.push(id); window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:false }, '*'); }
134    else if (wasSelected && !isRequired) { el.classList.add('extra'); extras.push(id); window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:false }, '*'); }
135    el.style.pointerEvents = 'none';
136  });
137
138  const perfect = missed.length === 0 && extras.length === 0;
139  const xp = perfect ? (${d.xp_per_perfect_round || 25}) : (correct > 0 ? ${d.xp_per_partial_round || 10} : 0);
140  updateScore(xp);
141
142  let fbHtml = '';
143  if (perfect) fbHtml = '<div class="feedback-item" style="border-left:3px solid var(--accent-success);">✅ Perfect! All correct PPE selected.</div>';
144  missed.forEach(id => {
145    const fb = round.feedback_missed?.[id] || 'This item was required.';
146    const label = PPE_ITEMS.find(p => p.id === id)?.label || id;
147    fbHtml += '<div class="feedback-item missed-fb">⚠️ <strong>Missed: ' + label + '</strong> — ' + fb + '</div>';
148  });
149  extras.forEach(id => {
150    const fb = round.feedback_extra?.[id] || 'This item was not needed.';
151    const label = PPE_ITEMS.find(p => p.id === id)?.label || id;
152    fbHtml += '<div class="feedback-item extra-fb">ℹ️ <strong>Extra: ' + label + '</strong> — ' + fb + '</div>';
153  });
154
155  document.getElementById('round-feedback').innerHTML = fbHtml;
156  document.getElementById('round-feedback').classList.remove('hidden');
157  document.getElementById('next-round-wrap').classList.remove('hidden');
158}
159
160document.getElementById('next-round-btn').addEventListener('click', () => { currentRound++; startRound(); });
161
162function updateScore(points) {
163  SECTION_DATA.score = Math.min(SECTION_DATA.score + points, 100);
164  document.getElementById('score-value').textContent = SECTION_DATA.score;
165}
166
167function endGame() {
168  document.getElementById('task-card').innerHTML = '<h3>Complete!</h3><p>You scored ' + SECTION_DATA.score + ' / 100</p>';
169  document.getElementById('ppe-grid').innerHTML = '';
170  document.getElementById('next-round-wrap').classList.add('hidden');
171  SECTION_DATA.completed = true;
172  SECTION_DATA.timeSpent = Math.round((Date.now() - SECTION_DATA.startTime) / 1000);
173  document.getElementById('btn-complete').disabled = false;
174}
175
176document.getElementById('btn-complete').addEventListener('click', () => {
177  window.parent.postMessage({ type: 'sectionComplete', sectionId: SECTION_DATA.sectionId, score: SECTION_DATA.score, maxScore: 100 }, '*');
178  showSectionCompleteOverlay(SECTION_DATA.score);
179});
180
181startRound();
182
183// ── Course context (received from hub world) ──
184var COURSE_CTX = { sectionNumber: 0, totalSections: 0 };
185window.addEventListener('message', function(e) {
186  if (e.data && e.data.type === 'courseContext') {
187    COURSE_CTX.sectionNumber = e.data.sectionNumber || 0;
188    COURSE_CTX.totalSections = e.data.totalSections || 0;
189  }
190});
191
192function showSectionCompleteOverlay(score) {
193  var ov = document.createElement('div');
194  ov.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(15,17,23,0.95);display:flex;align-items:center;justify-content:center;z-index:9999;';
195  var inner = document.createElement('div');
196  inner.style.cssText = 'text-align:center;color:#fff;max-width:400px;padding:32px;';
197  inner.innerHTML = '<h2 style="color:#8b5cf6;margin:0 0 12px;">Section Complete!</h2><p style="font-size:48px;font-weight:700;color:' + (score >= 80 ? '#22c55e' : '#f59e0b') + ';margin:0 0 8px;">' + (score || 0) + '%</p>';
198  var btnWrap = document.createElement('div');
199  btnWrap.style.cssText = 'display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:24px;';
200  if (COURSE_CTX.sectionNumber > 0 && COURSE_CTX.sectionNumber < COURSE_CTX.totalSections) {
201    var nextBtn = document.createElement('button');
202    nextBtn.textContent = 'Next Section \u2192';
203    nextBtn.style.cssText = 'background:#8b5cf6;color:#fff;border:none;padding:12px 24px;border-radius:8px;font-size:15px;cursor:pointer;';
204    nextBtn.addEventListener('click', function() { window.parent.postMessage({type:'loadNextSection'}, '*'); });
205    btnWrap.appendChild(nextBtn);
206  }
207  var backBtn = document.createElement('button');
208  backBtn.textContent = 'Back to Hub';
209  backBtn.style.cssText = 'background:#2a2a3e;color:#fff;border:1px solid #444;padding:12px 24px;border-radius:8px;font-size:15px;cursor:pointer;';
210  backBtn.addEventListener('click', function() { window.parent.postMessage({type:'returnToHub'}, '*'); });
211  btnWrap.appendChild(backBtn);
212  inner.appendChild(btnWrap);
213  ov.appendChild(inner);
214  document.body.appendChild(ov);
215}
216  </script>
217</body>
218</html>`;
219}
220
221function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
222
223const GAME_CSS = (theme) => `
224${getThemeVars(theme)}
225* { box-sizing:border-box; margin:0; padding:0; }
226body { font-family:'DM Sans',sans-serif; font-size:16px; line-height:1.6; color:var(--text-primary); background:var(--bg-primary); }
227.section-wrapper { max-width:900px; margin:0 auto; padding:24px 16px; min-height:100vh; display:flex; flex-direction:column; }
228.section-header { text-align:center; margin-bottom:24px; }
229.section-badge { display:inline-block; background:var(--bg-card); border:1px solid var(--border-default); border-radius:20px; padding:4px 16px; font-size:0.8rem; color:var(--text-secondary); margin-bottom:12px; }
230.section-title { font-size:1.75rem; font-weight:700; }
231.section-instruction { color:var(--text-secondary); font-size:1rem; max-width:600px; margin:0 auto; }
232.game-area { flex:1; }
233.section-footer { display:flex; justify-content:space-between; align-items:center; margin-top:32px; padding-top:24px; border-top:1px solid var(--border-default); }
234.score-display { display:flex; align-items:baseline; gap:6px; }
235.score-label { font-size:0.85rem; color:var(--text-secondary); }
236.score-value { font-size:1.5rem; font-weight:700; color:var(--accent-primary); }
237.score-max { font-size:0.85rem; color:var(--text-muted); }
238.btn { font-family:'DM Sans',sans-serif; font-size:1rem; font-weight:600; padding:12px 24px; border-radius:8px; border:none; cursor:pointer; transition:all 0.2s; }
239.btn-primary { background:var(--accent-primary); color:#000; }
240.btn-primary:hover { background:#d97706; }
241.btn-primary:disabled { background:var(--bg-card); color:var(--text-muted); cursor:not-allowed; }
242.btn-complete { background:var(--accent-success); color:#fff; font-size:1rem; font-weight:600; padding:12px 24px; border-radius:8px; border:none; cursor:pointer; }
243.btn-complete:disabled { background:var(--bg-card); color:var(--text-muted); cursor:not-allowed; }
244.hidden { display:none!important; }
245*:focus-visible { outline:2px solid var(--accent-primary); outline-offset:2px; }
246@media (prefers-reduced-motion:reduce) { *,*::before,*::after { animation-duration:0.01ms!important; transition-duration:0.01ms!important; } }
247`;

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.