PageSourceSearch

https://millionaire.ng/play/app.js?v=4

js millionaire.ng collected 2026-10-01 12:38:27 UTC 20,452 bytes, 581 lines download raw bytes

1const AUTH_KEY = 'quizstorm_token';
2
3function getToken() {
4  return localStorage.getItem(AUTH_KEY);
5}
6
7async function apiFetch(url, options = {}) {
8  const token = getToken();
9  const headers = { 'Content-Type': 'application/json', ...options.headers };
10  if (token) headers.Authorization = 'Bearer ' + token;
11  return fetch(url, { ...options, headers });
12}
13
14const socket = io({ auth: { token: getToken() } });
15
16const state = {
17  pin: '',
18  player: null,
19  timer: null,
20  score: 0
21};
22
23const screens = {
24  join: document.querySelector('#join-screen'),
25  wait: document.querySelector('#wait-screen'),
26  answer: document.querySelector('#answer-screen'),
27  advert: document.querySelector('#advert-screen'),
28  slot: document.querySelector('#slot-screen'),
29  submitted: document.querySelector('#submitted-screen'),
30  result: document.querySelector('#result-screen'),
31  final: document.querySelector('#final-screen')
32};
33
34const joinMessage = document.querySelector('#join-message');
35const playerName = document.querySelector('#player-name');
36const questionNumber = document.querySelector('#question-number');
37const playerScore = document.querySelector('#player-score');
38const timerBar = document.querySelector('#timer-bar');
39const timerSeconds = document.querySelector('#timer-seconds');
40const advertTimerBar = document.querySelector('#advert-timer-bar');
41const advertSeconds = document.querySelector('#advert-seconds');
42const advertNumber = document.querySelector('#advert-number');
43const advertText = document.querySelector('#advert-text');
44const advertMedia = document.querySelector('#advert-media');
45const slotTimerBar = document.querySelector('#slot-timer-bar');
46const slotSeconds = document.querySelector('#slot-seconds');
47const slotNumber = document.querySelector('#slot-number');
48const slotText = document.querySelector('#slot-text');
49const slotLeverImage = document.querySelector('#slot-lever-image');
50const slotReels = document.querySelector('#slot-reels');
51const slotWinners = document.querySelector('#slot-winners');
52const answerGrid = document.querySelector('#answer-grid');
53const colorOptions = document.querySelector('#color-options');
54const questionText = document.querySelector('#question-text');
55const questionMedia = document.querySelector('#question-media');
56const playerCorrectAnswer = document.querySelector('#player-correct-answer');
57const resultEffect = document.querySelector('#result-effect');
58const rankStatus = document.querySelector('#rank-status');
59const finalRank = document.querySelector('#final-rank');
60const finalLeaderboard = document.querySelector('#final-leaderboard');
61const playerAuthStatus = document.querySelector('#player-auth-status');
62
63const initialPin = new URLSearchParams(window.location.search).get('pin');
64if (/^\d{6}$/.test(initialPin || '')) {
65  document.querySelector('#pin-input').value = initialPin;
66}
67
68async function loadPlayerAccount() {
69  const token = getToken();
70  if (!token) {
71    playerAuthStatus.innerHTML = '<a href="/#register">Sign in</a>';
72    return;
73  }
74
75  try {
76    const response = await apiFetch('/api/auth/me');
77    const data = await response.json();
78    if (!response.ok) throw new Error('Invalid login.');
79    playerAuthStatus.textContent = 'Signed in as ' + data.user.username;
80    if (!document.querySelector('#name-input').value) {
81      document.querySelector('#name-input').value = data.user.username;
82    }
83  } catch (error) {
84    localStorage.removeItem(AUTH_KEY);
85    playerAuthStatus.innerHTML = '<a href="/#register">Sign in</a>';
86  }
87}
88
89// ── Countdown DOM refs ────────────────────────────────────────
90const countdownOverlay = document.querySelector('#countdown-overlay');
91const countdownNumber = document.querySelector('#countdown-number');
92let countdownTimer = null;
93
94function showCountdown(countdownMs) {
95  clearInterval(countdownTimer);
96  Sound.countdown();
97  countdownOverlay.classList.add('active');
98
99  const steps = ['3', '2', '1', 'GO!'];
100  const stepMs = countdownMs / steps.length;
101  let step = 0;
102
103  function tick() {
104    if (step >= steps.length) {
105      stopCountdown();
106      return;
107    }
108    countdownNumber.textContent = steps[step];
109    countdownNumber.style.animation = 'none';
110    void countdownNumber.offsetWidth;
111    countdownNumber.style.animation = 'countdown-pop ' + (stepMs / 1000) + 's ease-out forwards';
112    step++;
113  }
114
115  tick();
116  countdownTimer = setInterval(tick, stepMs);
117}
118
119function stopCountdown() {
120  clearInterval(countdownTimer);
121  countdownTimer = null;
122  countdownOverlay.classList.remove('active');
123  countdownNumber.textContent = '';
124}
125
126function showScreen(name) {
127  Object.values(screens).forEach((screen) => screen.classList.remove('active'));
128  screens[name].classList.add('active');
129}
130
131function escapeHtml(value) {
132  return String(value)
133    .replaceAll('&', '&amp;')
134    .replaceAll('<', '&lt;')
135    .replaceAll('>', '&gt;')
136    .replaceAll('"', '&quot;')
137    .replaceAll("'", '&#039;');
138}
139
140function startTimer(startedAt, endsAt) {
141  clearInterval(state.timer);
142  const total = endsAt - startedAt;
143
144  function tick() {
145    const remaining = Math.max(endsAt - Date.now(), 0);
146    const percentage = total > 0 ? (remaining / total) * 100 : 0;
147    const secs = Math.ceil(remaining / 1000);
148    timerBar.style.width = `${percentage}%`;
149    timerSeconds.textContent = secs;
150
151    if (percentage > 50) {
152      timerBar.style.background = '#22c55e';
153    } else if (percentage > 25) {
154      timerBar.style.background = '#eab308';
155    } else {
156      timerBar.style.background = '#ef4444';
157    }
158  }
159
160  tick();
161  state.timer = setInterval(tick, 100);
162}
163
164function startAdvertTimer(startedAt, endsAt) {
165  clearInterval(state.timer);
166  const total = endsAt - startedAt;
167
168  function tick() {
169    const remaining = Math.max(endsAt - Date.now(), 0);
170    const percentage = total > 0 ? (remaining / total) * 100 : 0;
171    advertTimerBar.style.width = `${percentage}%`;
172    advertSeconds.textContent = Math.ceil(remaining / 1000);
173
174    if (percentage > 50) {
175      advertTimerBar.style.background = '#22c55e';
176    } else if (percentage > 25) {
177      advertTimerBar.style.background = '#eab308';
178    } else {
179      advertTimerBar.style.background = '#ef4444';
180    }
181  }
182
183  tick();
184  state.timer = setInterval(tick, 100);
185}
186
187function startSlotTimer(startedAt, endsAt) {
188  clearInterval(state.timer);
189  const total = endsAt - startedAt;
190
191  function tick() {
192    const remaining = Math.max(endsAt - Date.now(), 0);
193    const percentage = total > 0 ? (remaining / total) * 100 : 0;
194    slotTimerBar.style.width = `${percentage}%`;
195    slotSeconds.textContent = Math.ceil(remaining / 1000);
196
197    if (percentage > 50) {
198      slotTimerBar.style.background = '#22c55e';
199    } else if (percentage > 25) {
200      slotTimerBar.style.background = '#eab308';
201    } else {
202      slotTimerBar.style.background = '#ef4444';
203    }
204  }
205
206  tick();
207  state.timer = setInterval(tick, 100);
208}
209
210function renderAdvertMedia(advert) {
211  advertMedia.innerHTML = '';
212  if (!advert || !advert.advert_media_url) return;
213
214  if (advert.advert_media_type === 'image') {
215    advertMedia.innerHTML = `<img src="${escapeHtml(advert.advert_media_url)}" alt="">`;
216    return;
217  }
218
219  if (advert.advert_media_type === 'video') {
220    advertMedia.innerHTML = `
221      <video src="${escapeHtml(advert.advert_media_url)}" autoplay muted playsinline controls></video>
222    `;
223    const video = advertMedia.querySelector('video');
224    if (video) video.play().catch(() => {});
225  }
226}
227
228function renderQuestionMedia(question) {
229  questionMedia.innerHTML = '';
230  if (!question || !question.question_media_url) return;
231  if (question.question_media_type === 'image') {
232    questionMedia.innerHTML = `<img src="${escapeHtml(question.question_media_url)}" alt="">`;
233  }
234}
235
236function renderLeaderboard(players) {
237  finalLeaderboard.innerHTML = players
238    .map((player) => `<li><span>${escapeHtml(player.name)}</span><span>${player.score}</span></li>`)
239    .join('');
240}
241
242function renderSlotReels(players, winners) {
243  const names = (players.length ? players : winners).map((player) => player.name || 'Player');
244  const winnerNames = winners.map((player) => player.name || 'Winner');
245  slotWinners.innerHTML = '';
246  slotReels.innerHTML = [0, 1, 2].map((index) => {
247    const winner = winnerNames[index] || winnerNames[winnerNames.length - 1] || 'Winner';
248    const pool = names.length ? names : [winner];
249    const displayNames = [];
250    for (let i = 0; i < 18; i += 1) displayNames.push(pool[i % pool.length]);
251    displayNames.push(winner);
252    displayNames.push(...pool.slice(0, 4));
253    const stopY = -((displayNames.lastIndexOf(winner) - 1) * 58);
254    return `
255      <div class="slot-reel">
256        <div class="slot-window"></div>
257        <div class="slot-track" style="--slot-stop:${stopY}px; animation-duration:7s">
258          ${displayNames.map((name) => `<span>${escapeHtml(name)}</span>`).join('')}
259        </div>
260      </div>
261    `;
262  }).join('');
263}
264
265function renderSlotReady(players) {
266  const names = players.map((player) => player.name || 'Player');
267  slotReels.innerHTML = `
268    <div class="slot-ready-state">
269      <strong>${names.length} player${names.length === 1 ? '' : 's'} loaded</strong>
270      <span>Waiting for host to pull the lever...</span>
271    </div>
272  `;
273  slotWinners.innerHTML = '';
274}
275
276function renderSlotWinners(winners) {
277  slotWinners.innerHTML = winners
278    .map((winner, index) => `<li><span>#${index + 1}</span><strong>${escapeHtml(winner.name || 'Winner')}</strong></li>`)
279    .join('');
280}
281
282function clearResultEffect() {
283  resultEffect.className = 'result-effect';
284  resultEffect.innerHTML = '';
285}
286
287function showCorrectEffect() {
288  const colors = ['#f8c537', '#22c55e', '#38bdf8', '#f472b6', '#f97316', '#a78bfa'];
289  clearResultEffect();
290  resultEffect.classList.add('celebrate');
291  resultEffect.innerHTML =
292    '<div class="result-face happy-face"><span class="face-eye left"></span><span class="face-eye right"></span><span class="face-mouth smile"></span></div>' +
293    Array.from({ length: 34 }, (_, index) => {
294      const color = colors[index % colors.length];
295      const left = 4 + Math.random() * 92;
296      const delay = Math.random() * 0.45;
297      const duration = 1.8 + Math.random() * 1.2;
298      const rotate = -80 + Math.random() * 160;
299      return `<span class="confetti-piece" style="--x:${left}%; --delay:${delay}s; --duration:${duration}s; --rotate:${rotate}deg; --color:${color};"></span>`;
300    }).join('');
301}
302
303function showWrongEffect() {
304  clearResultEffect();
305  resultEffect.classList.add('missed');
306  resultEffect.innerHTML =
307    '<div class="storm-cloud cloud-left"></div><div class="storm-cloud cloud-right"></div><div class="lightning bolt-left"></div><div class="lightning bolt-right"></div>' +
308    '<div class="result-face sad-face"><span class="face-eye left"></span><span class="face-eye right"></span><span class="face-mouth frown"></span></div>' +
309    Array.from({ length: 12 }, (_, index) => {
310      const left = 10 + Math.random() * 80;
311      const delay = index * 0.08;
312      const duration = 1.2 + Math.random() * 0.8;
313      return `<span class="tear-drop" style="--x:${left}%; --delay:${delay}s; --duration:${duration}s;"></span>`;
314    }).join('');
315}
316
317document.querySelector('#join-form').addEventListener('submit', (event) => {
318  event.preventDefault();
319  joinMessage.textContent = '';
320  const pin = document.querySelector('#pin-input').value.trim();
321  const name = document.querySelector('#name-input').value.trim();
322  socket.emit('player:join', { pin, name });
323});
324
325socket.on('player:joined', ({ pin, player }) => {
326  Sound.join();
327  state.pin = pin;
328  state.player = player;
329  state.score = player.score;
330  playerName.textContent = player.name;
331  playerScore.textContent = `${state.score} pts`;
332  showScreen('wait');
333});
334
335socket.on('player:reconnected', ({ pin, player, gameStatus }) => {
336  state.pin = pin;
337  state.player = player;
338  state.score = player.score;
339  playerName.textContent = player.name;
340  playerScore.textContent = `${state.score} pts`;
341  // Show wait screen — they'll catch the next question:started from host
342  showScreen('wait');
343  joinMessage.textContent = '';
344});
345
346socket.on('game:started', () => {
347  Sound.gameStart();
348  showScreen('wait');
349});
350
351socket.on('question:countdown', ({ countdownMs }) => {
352  advertMedia.innerHTML = '';
353  questionMedia.innerHTML = '';
354  showScreen('answer');
355  answerGrid.innerHTML = '';
356  colorOptions.innerHTML = '';
357  colorOptions.classList.remove('active');
358  showCountdown(countdownMs);
359});
360
361socket.on('question:started', (payload) => {
362  stopCountdown();
363  questionNumber.textContent = `Question ${payload.questionIndex + 1} of ${payload.totalQuestions}`;
364  playerScore.textContent = `${state.score} pts`;
365  questionText.textContent = payload.question.question_text;
366  renderQuestionMedia(payload.question);
367
368  const questionType = payload.question.question_type || 'multiple_choice';
369  const isTrueFalse = questionType === 'true_false';
370  const isColorChoice = questionType === 'color_choice';
371  const isImageChoice = questionType === 'image_choice';
372
373  if (isColorChoice) {
374    const colorNames = ['Red', 'Blue', 'Yellow', 'Green'];
375    const shapeNames = ['Square', 'Circle', 'Triangle', 'Star'];
376    const shapeClasses = ['shape-square', 'shape-circle', 'shape-triangle', 'shape-star'];
377    const colorClasses = ['color-red', 'color-blue', 'color-yellow', 'color-green'];
378    colorOptions.className = 'color-options active';
379    colorOptions.innerHTML = payload.question.answers
380      .map((answer, index) => `
381        <div class="color-option ${colorClasses[index] || ''}">
382          <span class="color-option-name">${colorNames[index] || 'Color'}</span>
383          <span class="color-option-text">${escapeHtml(answer.answer_text)}</span>
384        </div>
385      `)
386      .join('');
387    answerGrid.className = 'answer-grid answer-grid-shapes';
388
389    answerGrid.innerHTML = payload.question.answers
390      .map((answer, index) => `
391        <button class="answer answer-shape" data-answer-id="${answer.id}" aria-label="${colorNames[index] || 'Color'} ${shapeNames[index] || 'shape'}">
392          <span class="shape ${shapeClasses[index] || ''}"></span>
393          <span class="shape-label">${colorNames[index] || ''}</span>
394        </button>
395      `)
396      .join('');
397  } else if (isImageChoice) {
398    colorOptions.innerHTML = '';
399    colorOptions.classList.remove('active');
400    const letters = ['A', 'B', 'C', 'D'];
401    answerGrid.className = 'answer-grid answer-grid-images';
402
403    answerGrid.innerHTML = payload.question.answers
404      .map((answer, index) => {
405        const label = answer.answer_text ? `<span class="image-answer-label">${escapeHtml(answer.answer_text)}</span>` : '';
406        const image = answer.answer_media_url ? `<img src="${escapeHtml(answer.answer_media_url)}" alt="">` : '';
407        return `
408          <button class="answer answer-image" data-answer-id="${answer.id}">
409            <span class="answer-image-letter">${letters[index] || '?'}</span>
410            ${image}
411            ${label}
412          </button>
413        `;
414      })
415      .join('');
416  } else {
417    colorOptions.innerHTML = '';
418    colorOptions.classList.remove('active');
419    const letters = isTrueFalse ? ['T', 'F'] : ['A', 'B', 'C', 'D'];
420    const classes = isTrueFalse
421      ? ['answer answer-true', 'answer answer-false']
422      : ['answer answer-a', 'answer answer-b', 'answer answer-c', 'answer answer-d'];
423    answerGrid.className = 'answer-grid';
424
425    answerGrid.innerHTML = payload.question.answers
426      .map((answer, index) => {
427        const letter = letters[index] || '?';
428        const className = classes[index] || 'answer-a';
429        return `
430          <button class="${className}" data-answer-id="${answer.id}">
431            <span>${letter}</span>${escapeHtml(answer.answer_text)}
432          </button>
433        `;
434      })
435      .join('');
436  }
437
438  answerGrid.querySelectorAll('.answer').forEach((button) => {
439    button.addEventListener('click', () => {
440      answerGrid.querySelectorAll('.answer').forEach((item) => {
441        item.disabled = true;
442        item.classList.remove('selected');
443      });
444      button.classList.add('selected');
445      socket.emit('player:answer', {
446        pin: state.pin,
447        questionIndex: payload.questionIndex,
448        answerId: Number(button.dataset.answerId)
449      });
450      showScreen('submitted');
451    });
452  });
453
454  startTimer(payload.startedAt, payload.endsAt);
455  showScreen('answer');
456});
457
458socket.on('advert:started', (payload) => {
459  stopCountdown();
460  clearInterval(state.timer);
461  answerGrid.innerHTML = '';
462  colorOptions.innerHTML = '';
463  colorOptions.classList.remove('active');
464  advertNumber.textContent = `Advert ${payload.itemIndex + 1} of ${payload.totalItems}`;
465  advertText.textContent = payload.advert.advert_text || 'Advert';
466  renderAdvertMedia(payload.advert);
467  startAdvertTimer(payload.startedAt, payload.endsAt);
468  showScreen('advert');
469});
470
471socket.on('advert:ended', () => {
472  clearInterval(state.timer);
473  advertMedia.innerHTML = '';
474  showScreen('wait');
475});
476
477socket.on('slot:ready', (payload) => {
478  stopCountdown();
479  clearInterval(state.timer);
480  answerGrid.innerHTML = '';
481  colorOptions.innerHTML = '';
482  colorOptions.classList.remove('active');
483  advertMedia.innerHTML = '';
484  questionMedia.innerHTML = '';
485  slotTimerBar.style.width = '100%';
486  slotSeconds.textContent = '7';
487  slotNumber.textContent = `Slot Spin ${payload.itemIndex + 1} of ${payload.totalItems}`;
488  slotText.textContent = payload.slot.slot_text || 'Slot Spin';
489  slotLeverImage.src = '/assets/slot-ready.png';
490  renderSlotReady(payload.players || []);
491  showScreen('slot');
492});
493
494socket.on('slot:started', (payload) => {
495  stopCountdown();
496  clearInterval(state.timer);
497  answerGrid.innerHTML = '';
498  colorOptions.innerHTML = '';
499  colorOptions.classList.remove('active');
500  advertMedia.innerHTML = '';
501  questionMedia.innerHTML = '';
502  slotNumber.textContent = `Slot Spin ${payload.itemIndex + 1} of ${payload.totalItems}`;
503  slotText.textContent = payload.slot.slot_text || 'Slot Spin';
504  slotLeverImage.src = '/assets/slot-go.png';
505  renderSlotReels(payload.players || [], payload.winners || []);
506  startSlotTimer(payload.startedAt, payload.endsAt);
507  showScreen('slot');
508});
509
510socket.on('slot:ended', ({ winners }) => {
511  clearInterval(state.timer);
512  renderSlotWinners(winners || []);
513});
514
515socket.on('answer:accepted', () => {
516  showScreen('submitted');
517});
518
519socket.on('question:ended', ({ correctAnswer, leaderboard }) => {
520  clearInterval(state.timer);
521  const prevScore = state.score;
522  const me = leaderboard.find((player) => player.id === state.player.id);
523  const rank = leaderboard.findIndex((player) => player.id === state.player.id) + 1;
524  if (me) {
525    state.score = me.score;
526    playerScore.textContent = `${state.score} pts`;
527  }
528
529  const isCorrect = state.score > prevScore;
530  const resultScreen = screens.result;
531
532  // Flash background — green for correct, red for wrong
533  resultScreen.classList.remove('correct', 'wrong', 'result-flash');
534  void resultScreen.offsetWidth;
535  resultScreen.classList.add('result-flash', isCorrect ? 'correct' : 'wrong');
536  if (isCorrect) {
537    showCorrectEffect();
538    Sound.correct();
539  } else {
540    showWrongEffect();
541    Sound.wrong();
542  }
543
544  // Reveal animations
545  playerCorrectAnswer.textContent = correctAnswer.answer_text;
546  playerCorrectAnswer.classList.remove('reveal');
547  void playerCorrectAnswer.offsetWidth;
548  playerCorrectAnswer.classList.add('reveal');
549
550  rankStatus.textContent = rank > 0 ? `You are #${rank} with ${state.score} points` : `${state.score} points`;
551  rankStatus.classList.remove('reveal');
552  void rankStatus.offsetWidth;
553  rankStatus.classList.add('reveal');
554
555  showScreen('result');
556});
557
558socket.on('game:finished', ({ leaderboard }) => {
559  Sound.gameOver();
560  const rank = leaderboard.findIndex((player) => player.id === state.player.id) + 1;
561  finalRank.textContent = rank > 0 ? `You finished #${rank}` : 'Game over';
562  renderLeaderboard(leaderboard);
563  showScreen('final');
564});
565
566socket.on('error', ({ message }) => {
567  joinMessage.textContent = message;
568  if (screens.submitted.classList.contains('active')) {
569    showScreen('answer');
570  }
571});
572
573socket.on('player:kicked', () => {
574  Sound.kick();
575  state.pin = '';
576  state.player = null;
577  showScreen('join');
578  joinMessage.textContent = 'You were removed from the game by the host.';
579});
580
581loadPlayerAccount();

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.