/** * 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.storesInited = false; this.initStore(); this.initListeners(); this.checkForReferral(); } initElements() { this.selectors = { copyBtn: '.copy-btn', checkCode: '.check-code-btn', submit: '[type=submit]', recentList: '.recent-referrals-list', invite: 'form.invite', adminList: '.items-list.referral', dash: '.replace .referral-dashboard', stats: { codeUsed: '[data-stat="code_used"]', consultations: '[data-stat="consultations"]', treatments: '[data-stat="treatments"]', rewards: '[data-stat="total_rewards"]' }, list: '.referrals-list' }; this.forms = this.container.querySelectorAll('form'); this.popup = new window.jvbPopup({ 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 = new window.jvbTabs(this.container, {updateURL: false}); } this.ui = window.uiFromSelectors(this.selectors); this.dashTabs = null; if (this.ui.dash) { this.dashTabs = new window.jvbTabs(this.ui.dash); } if (!this.hasCopy) { document.querySelectorAll(this.selectors.copyBtn).forEach(btn => { btn.remove(); }); } this.formController = null; if (this.ui.invite) { this.formController = new window.jvbForm(); this.formController.registerForm( this.ui.invite, { autosave: true, endpoint: 'referrals', formStatus: false, } ); this.formController.subscribe((event, data) => { if (event === 'form-submit') { data = data.fullData; data.action = 'invite'; window.jvbQueue.addToQueue( { endpoint: 'referrals', data: data, title: 'Submitting invitations', } ); } }); } } 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)); } if (this.ui.dash) { this.initViewController(); } } initViewController() { if (!this.listStore || !this.ui.adminList) return; this.view = new window.jvbViews(this.ui.adminList, this.listStore); this.view.subscribe((event, data) => { switch(event) { case 'item-action': this.handleItemAction(data); break; case 'bulk-action': this.handleBulkAction(data); break; } }); } 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; } } /** * Handle item actions (remove, resend) */ handleItemAction(data) { const { action, itemId } = data; switch(action) { case 'remove': this.removeReferral(itemId); break; case 'resend': this.resendInvite(itemId); break; } } /** * Remove referral from list */ async removeReferral(id) { if (!confirm('Remove this referral from your list?')) return; try { const response = await fetch(`${jvbSettings.api}referrals`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.auth.getNonce() }, body: JSON.stringify({ action: 'remove', referral_id: id }) }); const result = await response.json(); if (result.success) { // Refresh DataStore if (this.listStore) this.listStore.fetch(); if (this.statsStore) this.statsStore.fetch(); this.a11y?.announce('Referral removed'); } } catch (error) { console.error('Error removing referral:', error); } } /** * Resend invite email */ async resendInvite(id) { try { const response = await fetch(`${jvbSettings.api}referrals`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.auth.getNonce() }, body: JSON.stringify({ action: 'resend', referral_id: id }) }); const result = await response.json(); if (result.success) { this.a11y?.announce('Invitation resent'); } else { alert(result.message || 'Cannot resend yet. Wait 7 days between invites.'); } } catch (error) { console.error('Error resending invite:', error); } } 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(() => { this.showCopySuccess(button); }).catch(() => { // Fallback to selection this.selectText(codeElement); this.showCopyFallback(button); }); } } /** * Select text in element */ selectText(element) { if (window.getSelection && document.createRange) { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } else if (document.body.createTextRange) { // IE fallback const range = document.body.createTextRange(); range.moveToElementText(element); range.select(); } } /** * Show copy success feedback */ showCopySuccess(button) { const originalHTML = button.innerHTML; button.innerHTML = window.jvbIcon('check', {size: 16}) + ' Copied!'; button.classList.add('success'); setTimeout(() => { button.innerHTML = originalHTML; button.classList.remove('success'); }, 2000); } /** * Show fallback message */ showCopyFallback(button) { const originalHTML = button.innerHTML; button.innerHTML = '✓ Selected - Press Ctrl+C'; button.classList.add('selected'); setTimeout(() => { button.innerHTML = originalHTML; button.classList.remove('selected'); }, 3000); } handleInput(e) { if (e.target.id === 'referral_code' || e.target.name === 'referral_code') { e.target.value = e.target.value.toUpperCase(); } } /** * 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(); } /** * Load user stats */ async loadStats() { const statsContainer = this.container.querySelector('.stats-summary'); if (!statsContainer) return; try { const response = await fetch(`${jvbSettings.api}referrals/my-stats?user=${window.auth.getUser()}`, { headers: { 'X-WP-Nonce': window.auth.getNonce() } }); const data = await response.json(); if (data.success && data.stats) { this.updateStats(data.stats); } } catch (error) { console.error('Error loading stats:', error); } } async loadSidebarStats() { try { const response = await fetch( `${jvbSettings.api}referrals/stats?user=${window.auth.getUser()}&type=quick`, { headers: { 'X-WP-Nonce': window.auth.getNonce() } } ); const data = await response.json(); if (data.success && data.stats) { this.updateSidebarStats(data.stats); } } catch (error) { console.error('Error loading sidebar stats:', error); } } /** * Update stats display */ updateStats(stats) { const elements = { total: this.container.querySelector('[data-stat="total"]'), treated: this.container.querySelector('[data-stat="treated"]'), pending: this.container.querySelector('[data-stat="pending"]'), rewards: this.container.querySelector('[data-stat="rewards"]') }; if (elements.total) elements.total.textContent = stats.total_referrals || 0; if (elements.treated) elements.treated.textContent = stats.treated_count || 0; if (elements.pending) elements.pending.textContent = stats.pending_count || 0; if (elements.rewards) { elements.rewards.textContent = '$' + parseFloat(stats.available_rewards || 0).toFixed(2); } } /** * 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 => `
${window.escapeHtml(ref.referee_name)} ${ref.referral_status}
${window.formatTimeAgo(ref.referred_at)}
`).join(''); } /** * Format date nicely */ formatDate(dateString) { const date = new Date(dateString); const now = new Date(); const diffTime = Math.abs(now - date); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 0) return 'Today'; if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays} days ago`; return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } /** * Handle form submission */ async handleFormSubmit(event) { event.preventDefault(); const form = event.target; const formData = new FormData(form); this.setFormLoading(true, form); try { let result = { success: false, message: '' }; if (form.id === 'referral-code-form') { // Registration with referral code - goes to LoginRoutes const data = { name: formData.get('referral_name'), email: formData.get('referral_email'), referral_code: formData.get('referral_code') }; if (!data.name || !data.email || !data.referral_code) { result.message = 'Please fill in all fields'; } else { result = await this.makeRequest('auth/register', data); // UPDATED endpoint } } else if (form.id === 'login-form') { const data = { type: 'login', email: formData.get('login_email'), context: { redirect_to: window.location.href + '?seeReferral=1' } }; result = await this.makeRequest('magic', data); } if (result.success) { this.handleSuccess(form, result); } else { this.showFormMessage(form, result.message || 'Something went wrong. Please try again.', 'error'); } } catch (error) { console.error('Error submitting form:', error); this.showFormMessage(form, 'Something went wrong. Please try again.', 'error'); } finally { this.setFormLoading(false, form); } } async makeRequest(endpoint, data) { const validEndpoints = [ 'magic', 'auth/register' ]; if (!validEndpoints.includes(endpoint)) { return { success: false, message: 'Invalid endpoint' }; } const response = await fetch(`${jvbSettings.api}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': window.auth.getNonce(), }, body: JSON.stringify(data) }); // Add error handling to see the actual response if (!response.ok) { const errorText = await response.text(); console.error('Error response:', response.status, errorText); try { return JSON.parse(errorText); } catch { return { success: false, message: 'Server error' }; } } return await response.json(); } /** * Show success state */ handleSuccess(form, result) { // Hide form form.style.display = 'none'; // Show success message const successDiv = form.nextElementSibling; if (successDiv && successDiv.classList.contains('success-content')) { successDiv.hidden = false; // Scroll to message successDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Fire custom event this.dispatchEvent('emailSent', { email: result.email }); } /** * Show message in form status area */ showFormMessage(form, text, type = 'error') { const status = form.querySelector('.status'); if (!status) return; const message = status.querySelector('.message'); if (message) { message.textContent = text; } status.hidden = false; status.className = `status ${type}`; if (type === 'error') { setTimeout(() => { status.hidden = true; }, 5000); } } /** * Set form loading state */ setFormLoading(loading, form) { const inputs = form.querySelectorAll('input, button'); inputs.forEach(input => input.disabled = loading); const status = form.querySelector('.status'); if (status) { status.classList.toggle('loading', loading); if (loading) { status.hidden = false; const message = status.querySelector('.message'); if (message) { message.textContent = 'Sending...'; } } } } /** * Dispatch custom event */ dispatchEvent(eventName, detail) { const event = new CustomEvent('referralWidget:' + eventName, { detail: detail, bubbles: true }); this.container.dispatchEvent(event); } } document.addEventListener('DOMContentLoaded', async function () { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbReferral = new Referral(); } }); });