Objection Handling Gym

// assets/ogym.js // All JS logic for the Objection Gym. // Enclosed in an IIFE to prevent global scope pollution. (function() { 'use strict'; // --- Constants and State --- const CONFIG_SELECTOR = '[data-ogym-config]'; const STATE_KEY = 'ogym_state_v1'; let config = {}; let state = { score: 0, streak: 0, accuracy: 0, reps: 0, totalTime: 0, mode: 'drill', objections: {}, history: [] }; let timer = null; let timeStart = 0; let currentScenario = null; let currentCards = []; let currentCardIndex = 0; // --- Utility Functions --- const $ = (selector, parent = document) => parent.querySelector(selector); const $$ = (selector, parent = document) => Array.from(parent.querySelectorAll(selector)); const getById = (id) => document.getElementById(id); const getByData = (key, value, parent = document) => $(`[data-ogym-${key}="${value}"]`, parent); const getElementInstance = (element) => { let instance = element.closest('[data-ogym-id]'); return instance ? instance.dataset.ogymId : null; }; const getRoot = (el) => el.closest('[data-ogym-id]'); const safeJsonParse = (str) => { try { return JSON.parse(str); } catch (e) { console.error('Error parsing JSON:', e); return []; } }; const shuffle = (array) => { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; }; const pushToDataLayer = (event, payload) => { if (window.dataLayer) { window.dataLayer.push({ event: event, ...payload }); } }; // --- State Persistence --- const loadState = () => { try { const savedState = localStorage.getItem(STATE_KEY); if (savedState) { Object.assign(state, JSON.parse(savedState)); } } catch (e) { console.error('Failed to load state from localStorage:', e); } }; const saveState = () => { try { localStorage.setItem(STATE_KEY, JSON.stringify(state)); // Attempt to save to Shopify metafield via cart attributes const body = { attributes: { ogym_progress: JSON.stringify(state) } }; fetch('/cart/update.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), keepalive: true }); } catch (e) { console.error('Failed to save state:', e); } }; // --- UI Updates --- const updateScoreboard = () => { const root = getById(config.sectionId); if (!root) return; const scoreEl = getByData('stat', 'score', root); const streakEl = getByData('stat', 'streak', root); const accuracyEl = getByData('stat', 'accuracy', root); const timeEl = getByData('stat', 'time', root); scoreEl.textContent = state.score; streakEl.textContent = state.streak; if (state.reps > 0) { const accuracy = (state.accuracy / state.reps * 100).toFixed(0); accuracyEl.textContent = `${accuracy}%`; const avgTime = (state.totalTime / state.reps / 1000).toFixed(1); timeEl.textContent = `${avgTime}s`; } }; const updateTimer = () => { const root = getById(config.sectionId); const progressEl = getByData('progress-path', null, root); const textEl = getByData('progress-text', null, root); if (!progressEl || !textEl) return; const timeRemaining = Math.max(0, config.timerSecs - Math.floor((Date.now() - timeStart) / 1000)); const percentage = (timeRemaining / config.timerSecs) * 100; const circumference = 2 * Math.PI * 15.9155; const offset = circumference - (percentage / 100) * circumference; progressEl.style.strokeDasharray = `${circumference} ${circumference}`; progressEl.style.strokeDashoffset = offset; if (timeRemaining > 0) { textEl.textContent = `${timeRemaining}s`; } else { textEl.textContent = 'Time Up!'; clearInterval(timer); handleOutcome('loss'); } }; // --- Core Logic --- const startTimer = () => { clearInterval(timer); timeStart = Date.now(); updateTimer(); timer = setInterval(updateTimer, 1000); }; const stopTimer = () => { clearInterval(timer); }; const getNextScenario = () => { const filtered = currentCards.filter(card => !card.classList.contains('is-active')); if (filtered.length === 0) { currentCards.forEach(card => card.classList.remove('is-active')); currentCards = shuffle(currentCards); } currentCardIndex = (currentCardIndex + 1) % currentCards.length; return currentCards[currentCardIndex]; }; const showScenario = (objection) => { const root = getById(config.sectionId); $$('.ogym-card', root).forEach(card => card.classList.remove('is-active')); const card = getByData('objection-id', objection.id); if (!card) { console.error('Card not found for objection ID:', objection.id); return; } card.classList.add('is-active'); // Hide all mode views and reset $$('[data-ogym-mode]').forEach(el => el.hidden = true); const modeView = getByData('ogym-mode', state.mode, card); if (modeView) { modeView.hidden = false; } // Reset feedback $$('[data-ogym-feedback]', card).forEach(el => el.hidden = true); const responseTextarea = getByData('response-textarea', null, card); if (responseTextarea) responseTextarea.value = ''; // Handle initial state of buttons const submitBtn = getByData('action', 'submit', card); if (submitBtn) submitBtn.disabled = false; // Start the timer if (config.timerOn) { startTimer(); } }; const handleOutcome = (outcome) => { stopTimer(); const timeTaken = Date.now() - timeStart; state.reps++; state.totalTime += timeTaken; let points = 0; let isCorrect = false; if (outcome === 'win') { const difficulty = state.currentScenario.difficulty || 1; points = 2 * difficulty; // +2 base, multiplied by difficulty (1-5) state.streak++; state.accuracy++; isCorrect = true; } else if (outcome === 'loss') { points = -1; state.streak = 0; isCorrect = false; } else if (outcome === 'quiz-correct') { points = 5; state.streak++; state.accuracy++; isCorrect = true; } else if (outcome === 'quiz-incorrect') { points = -2; state.streak = 0; isCorrect = false; } state.score = Math.max(0, state.score + points); updateScoreboard(); saveState(); // Push analytics event pushToDataLayer('ogym_answer', { set: state.currentScenario.set_title, objectionId: state.currentScenario.id, mode: state.mode, correct: isCorrect, timeMs: timeTaken, score: state.score }); }; const renderQuizOptions = (objection) => { const root = getById(config.sectionId); const quizOptionsEl = getByData('quiz-options', null, root); if (!quizOptionsEl) return; let options = [{ text: objection.gold, correct: true }]; // Add plausible incorrect options from other frameworks const otherFrameworks = config.sets .flatMap(set => set.objections) .flatMap(obj => obj.frameworks); // Select 2 random, unique frameworks for distractors const distractors = shuffle(otherFrameworks) .filter(fw => !objection.frameworks.some(f => f.template === fw.template)) .slice(0, 2) .map(fw => ({ text: fw.template, correct: false })); options = shuffle([...options, ...distractors]); quizOptionsEl.innerHTML = ''; options.forEach((option, index) => { const button = document.createElement('button'); button.classList.add('ogym-button', 'ogym-quiz-option'); button.textContent = option.text; button.setAttribute('aria-label', `Option ${index + 1}: ${option.text}`); button.dataset.correct = option.correct; button.addEventListener('click', (e) => { handleQuizAnswer(e.target, option.correct, objection.gold); }); quizOptionsEl.appendChild(button); }); }; const handleQuizAnswer = (target, isCorrect, goldResponse) => { stopTimer(); const resultBox = getByData('feedback', null, target.closest('.ogym-card')); const resultText = getByData('quiz-result', null, resultBox); if (isCorrect) { target.classList.add('ogym-quiz-option--correct'); resultText.textContent = 'Correct!'; handleOutcome('quiz-correct'); } else { target.classList.add('ogym-quiz-option--incorrect'); resultText.textContent = 'Incorrect.'; handleOutcome('quiz-incorrect'); } resultBox.hidden = false; $$('.ogym-quiz-option').forEach(btn => btn.disabled = true); }; const setMode = (mode) => { state.mode = mode; const root = getById(config.sectionId); $$('.ogym-card').forEach(card => { $$('[data-ogym-mode]', card).forEach(el => el.hidden = true); const modeView = getByData('ogym-mode', mode, card); if (modeView) modeView.hidden = false; }); nextScenario(); }; const nextScenario = () => { const root = getById(config.sectionId); const sets = $$('[data-ogym-set-id]', root); if (sets.length === 0) { console.error('No objection sets found.'); return; } const currentSet = sets[0]; // For now, just use the first set. const objections = safeJsonParse(getByData('objections', null, currentSet).textContent); state.currentScenario = { id: null, set_title: currentSet.dataset.ogymSetTitle, difficulty: parseInt(currentSet.dataset.ogymSetDifficulty), tags: currentSet.dataset.ogymSetTags, ...objections[Math.floor(Math.random() * objections.length)] }; state.currentScenario.id = state.currentScenario.id || `objection-${state.reps}`; showScenario(state.currentScenario); if (state.mode === 'quiz') { renderQuizOptions(state.currentScenario); } pushToDataLayer('ogym_start', { set: state.currentScenario.set_title, mode: state.mode }); }; // --- Event Listeners and Init --- const init = () => { const root = document.querySelector('[data-ogym-id]'); if (!root) return; config = safeJsonParse(root.querySelector(CONFIG_SELECTOR).textContent); loadState(); updateScoreboard(); // Event listeners root.addEventListener('click', (e) => { const target = e.target; if (target.matches('[data-ogym-action="next"]')) { nextScenario(); } else if (target.matches('[data-ogym-action="reset"]')) { state.score = 0; state.streak = 0; state.reps = 0; state.accuracy = 0; state.totalTime = 0; localStorage.removeItem(STATE_KEY); updateScoreboard(); nextScenario(); } else if (target.matches('[data-ogym-action="win"]')) { handleOutcome('win'); nextScenario(); } else if (target.matches('[data-ogym-action="loss"]')) { handleOutcome('loss'); nextScenario(); } }); // Mode and Timer controls const modeSelect = getByData('mode-select', null, root); if (modeSelect) { modeSelect.addEventListener('change', (e) => setMode(e.target.value)); setMode(modeSelect.value); } const timerToggle = getByData('timer-toggle', null, root); if (timerToggle) { timerToggle.addEventListener('change', (e) => { config.timerOn = e.target.checked; if (config.timerOn) { startTimer(); } else { stopTimer(); } }); } // Roleplay Framework buttons root.addEventListener('click', (e) => { const target = e.target; if (target.matches('[data-ogym-framework-template]')) { const textarea = getByData('response-textarea', null, target.closest('.ogym-card')); if (textarea) { textarea.value = target.dataset.ogymFrameworkTemplate; } } }); // Quiz and Roleplay Submission root.addEventListener('click', (e) => { const target = e.target; if (target.matches('[data-ogym-action="submit"]')) { // For Roleplay, this is a manual "win" handleOutcome('win'); nextScenario(); } }); // Keyboard shortcuts for frameworks document.addEventListener('keydown', (e) => { if (state.mode === 'roleplay') { const textarea = $('[data-ogym-response-textarea]:not([hidden])'); if (!textarea || textarea !== document.activeElement) return; const frameworks = $$('.ogym-button--framework', textarea.closest('.ogym-card')); if (e.key >= '1' && e.key <= '3' && frameworks[e.key - 1]) { frameworks[e.key - 1].click(); e.preventDefault(); } } }); // Initial render currentCards = $$('.ogym-objection-set'); nextScenario(); }; document.addEventListener('DOMContentLoaded', init); })();