/** * Referral Widget Manager * Handles both logged-in share widget and public code validation widget */ class Referral { constructor() { this.container = document.querySelector('aside.referral'); if (!this.container) { return; } this.a11y = window.jvbA11y; this.toggle = document.querySelector('button[data-action="toggle-referral"]'); this.hasCopy = navigator.clipboard && navigator.clipboard.writeText; this.initElements(); this.initStore(); this.initListeners(); this.checkForReferral(); } initElements() { this.selectors = { copyBtn: '.copy-btn', checkCode: '.check-code-btn', submit: '[type=submit]', recentList: '.recent-referrals-list', stats: { codeUsed: '[data-stat="code_used"]', consultations: '[data-stat="consultations"]', treatments: '[data-stat="treatments"]', rewards: '[data-stat="total_rewards"]' }, }; this.forms = this.container.querySelectorAll('form'); this.popup = window.jvbPopup.registerPopup({ toggle: this.toggle, popup: this.container, name: 'Referral Box', onOpen: () => { this.bindEventListeners(true); }, onClose: () => { this.bindEventListeners(false); } }); this.tabs = null; if (this.container.querySelector('nav.tabs')) { this.tabs = window.jvbTabs.registerTab(this.container, {updateURL: false}); } this.ui = window.uiFromSelectors(this.selectors); if (!this.hasCopy) { document.querySelectorAll(this.selectors.copyBtn).forEach(btn => { btn.remove(); }); } } initStore() { if (!this.isLoggedIn()) return; const stores = window.jvbStore.register( 'referrals', [ // Dashboard stats store { storeName: 'stats', keyPath: 'user_id', endpoint: 'referrals/stats', TTL: 5 * 60 * 1000, showLoading: false, delayFetch: false, filters: { type: 'dashboard', user: window.auth.getUser() } }, // Referrals list store { storeName: 'list', keyPath: 'id', endpoint: 'referrals', TTL: 10 * 60 * 1000, showLoading: false, delayFetch: false, filters: { user: window.auth.getUser(), status: 'all', limit: 50, offset: 0 } } ] ); this.statsStore = stores.stats; this.listStore = stores.list; // Subscribe to store events if (this.statsStore) { this.statsStore.subscribe(this.handleStatsEvent.bind(this)); } if (this.listStore) { this.listStore.subscribe(this.handleListEvent.bind(this)); } } initListeners() { this.clickHandler = this.handleClick.bind(this); this.inputHandler = this.handleInput.bind(this); this.submitHandler = this.handleFormSubmit.bind(this); } bindEventListeners(bind) { const method = bind ? 'addEventListener' : 'removeEventListener'; this.forms.forEach(form => { form[method]('submit', this.submitHandler); }); this.container[method]('click', this.clickHandler); this.container[method]('input', this.inputHandler); } isLoggedIn() { return Boolean(window.auth.getUser()); } /** * Handle DataStore stats events */ handleStatsEvent(event, data) { switch(event) { case 'data-loaded': if (data.items && data.items.length > 0) { this.updateStatsDisplay(); } break; case 'fetch-error': console.error('Error loading stats:', data.error); break; } } /** * Handle DataStore list events */ handleListEvent(event, data) { switch(event) { case 'data-loaded': // Let ViewController handle main list rendering // Only update sidebar preview if it exists if (this.ui.recentList) { this.renderRecentReferrals(); } break; case 'fetch-error': console.error('Error loading referrals:', data.error); break; } } /** * Update stats display */ updateStatsDisplay() { if (!this.statsStore.data.size === 0) return; let stats = this.statsStore.data.get(parseInt(window.auth.getUser())); const updates = { total: stats['code_used'] || 0, treated: stats.treatments || 0, pending: stats.pending || 0, rewards: '$' + parseFloat(stats['total_rewards'] || 0).toFixed(2) }; Object.entries(updates).forEach(([key, value]) => { const element = this.container.querySelector(`[data-stat="${key}"]`); if (element) { element.textContent = value; } }); // Also update stat cards if on dashboard const statCards = this.container.querySelectorAll('.stats .card'); if (statCards.length >= 4) { statCards[0].querySelector('.stat-number').textContent = updates.code_used; statCards[1].querySelector('.stat-number').textContent = updates.consultations; statCards[2].querySelector('.stat-number').textContent = updates.treatments; statCards[3].querySelector('.stat-number').textContent = updates.total_rewards; } } handleClick(e) { const target = e.target.closest('.copy-btn, .check-code-btn, .attn'); if (!target) return; if (target.classList.contains('copy-btn')) { this.handleCopyClick(target); } else if (target.classList.contains('check-code-btn')) { this.handleCheckCode(e); } else if (target.classList.contains('attn')) { target.classList.remove('attn'); } } /** * Handle copy button click with fallback */ handleCopyClick(button) { const targetId = button.dataset.target; const codeElement = this.container.querySelector(`#${targetId}`); if (!codeElement) return; const text = codeElement.textContent.trim(); // Try clipboard API first if (this.hasCopy) { navigator.clipboard.writeText(text).then(() => { button.classList.toggle('success'); setTimeout(() => { button.classList.remove('success'); }, 1500); }); } } /** * Handle error response with field-specific feedback */ handleError(form, result) { const { message, code, field } = result; // If there's a specific field, highlight it if (field) { this.showFieldError(form, field, message); } else { // Show general form error using FormController pattern this.showFormStatus(form, 'error', message || 'Something went wrong. Please try again.'); } // Handle specific error codes switch(code) { case 'duplicate_email': // Could add additional UI feedback break; case 'invalid_code': // Unlock the referral code field so user can correct it const codeInput = form.querySelector('[name="referral_code"]'); if (codeInput) { codeInput.readOnly = false; codeInput.focus(); } break; case 'turnstile_failed': // Refresh Turnstile widget if available if (window.turnstile && form.querySelector('.cf-turnstile')) { window.turnstile.reset(); } break; } } /** * Show error for specific field */ showFieldError(form, fieldName, message) { // Find the field wrapper (handles both direct names and referral_ prefixed names) let fieldWrapper = form.querySelector(`.field[data-field="${fieldName}"]`); if (!fieldWrapper) { fieldWrapper = form.querySelector(`.field[data-field="referral_${fieldName}"]`); } if (!fieldWrapper) { // If no field wrapper found, show as general form error this.showFormStatus(form, 'error', message); return; } const input = fieldWrapper.querySelector('input, textarea, select'); const validationMessage = fieldWrapper.querySelector('.validation-message'); const errorIcon = fieldWrapper.querySelector('.validation-icon.error'); const successIcon = fieldWrapper.querySelector('.validation-icon.success'); if (!input) { this.showFormStatus(form, 'error', message); return; } // Apply error state (following FormController pattern) fieldWrapper.classList.remove('has-success'); fieldWrapper.classList.add('has-error'); input.classList.add('error'); input.setAttribute('aria-invalid', 'true'); // Show error icon, hide success icon if (errorIcon) errorIcon.hidden = false; if (successIcon) successIcon.hidden = true; // Show error message if (validationMessage) { validationMessage.textContent = message; validationMessage.hidden = false; } // Focus the problematic field input.focus(); // Announce to screen readers this.a11y?.announce(`Error in ${fieldName}: ${message}`); } showFormStatus(form, status, message = '') { const statusWrap = form.querySelector('.fstatus'); if (!statusWrap) { console.warn('No .fstatus element found in form'); return; } statusWrap.hidden = false; const statusElement = statusWrap.querySelector('.message'); // Clear previous state statusWrap.querySelector('.icon')?.remove(); statusWrap.querySelector('.actions')?.remove(); // Status messages const messages = { 'saving': 'Sending...', 'submitted': 'Sent successfully!', 'error': 'Something went wrong', 'checking': 'Checking code...' }; // Status icons (using window.getIcon like FormController) const icons = { 'submitted': 'check-circle', 'error': 'close-circle', 'checking': 'loading' }; // Add icon if available if (icons[status] && window.getIcon) { const icon = window.getIcon(icons[status]); if (icon) { statusWrap.prepend(icon); } } // Set message if (statusElement) { statusElement.textContent = message || messages[status] || status; } // Add loading class for pending states statusWrap.classList.toggle('loading', ['saving', 'checking'].includes(status)); // Auto-hide success messages if (status === 'submitted') { setTimeout(() => statusWrap.hidden = true, 3000); } // Announce to screen readers if (this.a11y) { this.a11y.announce(message || messages[status] || status); } } /** * Clear all form errors */ clearFormErrors(form) { // Clear field-level errors form.querySelectorAll('.field.has-error, .field.has-success').forEach(fieldWrapper => { this.clearFieldValidation(fieldWrapper); }); // Hide form status const statusWrap = form.querySelector('.fstatus'); if (statusWrap) { statusWrap.hidden = true; } } clearFieldValidation(fieldWrapper) { if (!fieldWrapper) return; const input = fieldWrapper.querySelector('input, textarea, select'); const validationMessage = fieldWrapper.querySelector('.validation-message'); const validationIcons = fieldWrapper.querySelectorAll('.validation-icon'); // Remove classes fieldWrapper.classList.remove('has-error', 'has-success'); if (input) { input.classList.remove('error'); input.removeAttribute('aria-invalid'); } // Hide icons and messages validationIcons.forEach(icon => icon.hidden = true); if (validationMessage) { validationMessage.hidden = true; validationMessage.textContent = ''; } } handleInput(e) { if (e.target.id === 'referral_code' || e.target.name === 'referral_code') { e.target.value = e.target.value.toUpperCase(); } // Clear field error when user types const fieldWrapper = e.target.closest('.field'); if (fieldWrapper && fieldWrapper.classList.contains('has-error')) { this.clearFieldValidation(fieldWrapper); } } /** * Handle code verification */ async handleCheckCode(e) { e.preventDefault(); const form = e.target.closest('form'); const codeInput = form.querySelector('[name="referral_code"]'); const statusDiv = form.querySelector('.code-status'); if (!codeInput || !statusDiv) return; const code = codeInput.value.trim(); if (!code) { this.showCodeStatus(statusDiv, 'Please enter a code', 'error'); return; } // Show loading statusDiv.hidden = false; statusDiv.className = 'code-status loading'; statusDiv.innerHTML = ' Checking...'; try { const result = await this.validateCodeOnly(code); if (result.success) { this.showCodeStatus( statusDiv, `✓ Valid! Referred by ${result.referrer_name}`, 'success' ); } else { this.showCodeStatus(statusDiv, result.message || 'Invalid code', 'error'); } } catch (error) { console.error('Error checking code:', error); this.showCodeStatus(statusDiv, 'Error checking code', 'error'); } } /** * Show code verification status */ showCodeStatus(statusDiv, message, type) { statusDiv.hidden = false; statusDiv.className = `code-status ${type}`; statusDiv.textContent = message; if (type === 'error') { setTimeout(() => { statusDiv.hidden = true; }, 5000); } } /** * Check for ?ref parameter in URL and pre-fill code */ async checkForReferral() { const refCode = this.getUrlParameter('ref'); const refName = this.getUrlParameter('rname'); const refEmail = this.getUrlParameter('remail'); const seeReferral = this.getUrlParameter('seeReferral'); if (!refCode && !seeReferral) { return; } // If logged in user just wants to see referral popup if (seeReferral && !refCode) { this.popup.openPopup(); this.removeUrlParameter('seeReferral'); return; } const codeInput = this.container.querySelector('[name="referral_code"]'); if (!codeInput) return; // Convert to uppercase const code = refCode.toUpperCase(); // Pre-fill the code input codeInput.value = code; codeInput.readOnly = true; // If we have token data, prefill name and email too if (refName || refEmail) { const nameInput = this.container.querySelector('[name="referral_name"]'); if (nameInput) { nameInput.value = refName; } const emailInput = this.container.querySelector('[name="referral_email"]'); if (emailInput) { emailInput.value = refEmail; } } // Open the sidebar popup this.popup.openPopup(); // Validate the code immediately try { const referrer = await this.validateCodeOnly(code); if (referrer.success) { const statusDiv = codeInput.closest('form').querySelector('.code-status'); if (statusDiv) { this.showCodeStatus( statusDiv, `✓ ${referrer.referrer_name} invited you!`, 'success' ); } // Focus on name input if not prefilled const nameInput = this.container.querySelector('[name="referral_name"]'); if (nameInput && !nameInput.value) { nameInput.focus(); } } else { codeInput.readOnly = false; this.showMessage('This referral link is invalid. Please enter a valid code.', 'error'); } } catch (error) { console.error('Error validating code:', error); codeInput.readOnly = false; } // Clean up URL this.removeUrlParameter('ref'); this.removeUrlParameter('rname'); this.removeUrlParameter('remail'); } getUrlParameter(name) { const urlParams = new URLSearchParams(window.location.search); return urlParams.get(name); } removeUrlParameter(name) { const url = new URL(window.location); url.searchParams.delete(name); window.history.replaceState({}, document.title, url.toString()); } /** * Validate code without registering */ async validateCodeOnly(code) { const response = await fetch(`${jvbSettings.api}referrals/code`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.auth.getNonce() }, body: JSON.stringify({ code: code }) }); return await response.json(); } /** * Render recent referrals list */ renderRecentReferrals() { let container = this.ui.recentList; let referrals = Array.from(this.listStore.data.values()); if (!referrals || referrals.length === 0) { container.innerHTML = '
Share your code to get started!
'; return; } container.innerHTML = referrals.map(ref => `