| | |
| | | |
| | | class Referral { |
| | | constructor() { |
| | | this.container = document.querySelector('.jvb-referral'); |
| | | 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(); |
| | | |
| | | // Load additional data for logged-in users |
| | | if (this.isLoggedIn()) { |
| | | this.loadStats(); |
| | | this.loadRecentReferrals(); |
| | | } |
| | | } |
| | | |
| | | initElements() { |
| | |
| | | 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.tabs = null; |
| | | |
| | | if (this.container.querySelector('nav.tabs')) { |
| | | this.tabs = new window.jvbTabs(this.container, {updateURL: false}); |
| | | } |
| | | |
| | | this.ui = window.uiFromSelectors(this.selectors, this.container); |
| | | |
| | | 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() { |
| | |
| | | } |
| | | |
| | | isLoggedIn() { |
| | | return Boolean(jvbSettings.currentUser); |
| | | 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; |
| | |
| | | const text = codeElement.textContent.trim(); |
| | | |
| | | // Try clipboard API first |
| | | if (navigator.clipboard && navigator.clipboard.writeText) { |
| | | if (this.hasCopy) { |
| | | navigator.clipboard.writeText(text).then(() => { |
| | | this.showCopySuccess(button); |
| | | }).catch(() => { |
| | |
| | | this.selectText(codeElement); |
| | | this.showCopyFallback(button); |
| | | }); |
| | | } else { |
| | | // Fallback to selection |
| | | this.selectText(codeElement); |
| | | this.showCopyFallback(button); |
| | | } |
| | | } |
| | | |
| | |
| | | * Check for ?ref parameter in URL and pre-fill code |
| | | */ |
| | | async checkForReferral() { |
| | | const isLoggedIn = this.getUrlParameter('seeReferral'); |
| | | const refCode = this.getUrlParameter('ref'); |
| | | const refName = this.getUrlParameter('rname'); |
| | | const refEmail = this.getUrlParameter('remail'); |
| | | const seeReferral = this.getUrlParameter('seeReferral'); |
| | | |
| | | if (!isLoggedIn && !refCode) { |
| | | if (!refCode && !seeReferral) { |
| | | return; |
| | | } |
| | | |
| | | if (!refCode) { |
| | | // If logged in user just wants to see referral popup |
| | | if (seeReferral && !refCode) { |
| | | this.popup.openPopup(); |
| | | this.removeUrlParameter('seeReferral'); |
| | | return; |
| | | } |
| | | |
| | |
| | | codeInput.value = code; |
| | | codeInput.readOnly = true; |
| | | |
| | | this.popup.togglePopup(); |
| | | // 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 { |
| | |
| | | ); |
| | | } |
| | | |
| | | // Focus on name input |
| | | // Focus on name input if not prefilled |
| | | const nameInput = this.container.querySelector('[name="referral_name"]'); |
| | | if (nameInput) { |
| | | if (nameInput && !nameInput.value) { |
| | | nameInput.focus(); |
| | | } |
| | | } else { |
| | |
| | | |
| | | // Clean up URL |
| | | this.removeUrlParameter('ref'); |
| | | this.removeUrlParameter('rname'); |
| | | this.removeUrlParameter('remail'); |
| | | } |
| | | |
| | | getUrlParameter(name) { |
| | |
| | | * Validate code without registering |
| | | */ |
| | | async validateCodeOnly(code) { |
| | | const response = await fetch(`${jvbSettings.api}referrals/check-code`, { |
| | | const response = await fetch(`${jvbSettings.api}referrals/code`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce |
| | | 'X-WP-Nonce': window.auth.getNonce() |
| | | }, |
| | | body: JSON.stringify({ code: code }) |
| | | }); |
| | |
| | | if (!statsContainer) return; |
| | | |
| | | try { |
| | | const response = await fetch(`${jvbSettings.api}referrals/my-stats?user=${jvbSettings.currentUser}`, { |
| | | headers: { 'X-WP-Nonce': jvbSettings.nonce } |
| | | 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(); |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * Load recent referrals (last 5) |
| | | */ |
| | | async loadRecentReferrals() { |
| | | const container = this.container.querySelector('.recent-referrals-list'); |
| | | if (!container) return; |
| | | |
| | | try { |
| | | const response = await fetch(`${jvbSettings.api}referrals/my-referrals?limit=5&user=${jvbSettings.currentUser}`, { |
| | | headers: { 'X-WP-Nonce': jvbSettings.nonce } |
| | | }); |
| | | |
| | | const data = await response.json(); |
| | | if (data.success && data.referrals) { |
| | | this.renderRecentReferrals(container, data.referrals); |
| | | } else { |
| | | container.innerHTML = '<p class="no-referrals">No referrals yet</p>'; |
| | | } |
| | | } catch (error) { |
| | | console.error('Error loading referrals:', error); |
| | | container.innerHTML = '<p class="error">Failed to load referrals</p>'; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Render recent referrals list |
| | | */ |
| | | renderRecentReferrals(container, referrals) { |
| | | renderRecentReferrals() { |
| | | let container = this.ui.recentList; |
| | | let referrals = Array.from(this.listStore.data.values()); |
| | | if (!referrals || referrals.length === 0) { |
| | | container.innerHTML = '<p class="no-referrals">Share your code to get started!</p>'; |
| | | return; |
| | | } |
| | | |
| | | const html = referrals.map(ref => ` |
| | | container.innerHTML = referrals.map(ref => ` |
| | | <div class="referral-item"> |
| | | <div class="referral-info"> |
| | | <strong>${window.escapeHtml(ref.referee_name)}</strong> |
| | | <span class="status-badge ${ref.status}">${ref.status}</span> |
| | | <span class="status-badge">${ref.referral_status}</span> |
| | | </div> |
| | | <div class="referral-date">${this.formatDate(ref.referred_at)}</div> |
| | | <div class="referral-date">${window.formatTimeAgo(ref.referred_at)}</div> |
| | | </div> |
| | | `).join(''); |
| | | |
| | | container.innerHTML = html; |
| | | } |
| | | |
| | | /** |
| | |
| | | const form = event.target; |
| | | const formData = new FormData(form); |
| | | |
| | | // Disable 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'), |
| | | code: formData.get('referral_code') |
| | | referral_code: formData.get('referral_code') |
| | | }; |
| | | |
| | | if (!data.name || !data.email || !data.code) { |
| | | if (!data.name || !data.email || !data.referral_code) { |
| | | result.message = 'Please fill in all fields'; |
| | | } else { |
| | | result = await this.makeRequest('referrals/register', data); |
| | | result = await this.makeRequest('auth/register', data); // UPDATED endpoint |
| | | } |
| | | } else if (form.id === 'login-form') { |
| | | const data = { |
| | |
| | | async makeRequest(endpoint, data) { |
| | | const validEndpoints = [ |
| | | 'magic', |
| | | 'referrals/register', |
| | | 'referrals/check-code' |
| | | 'auth/register' |
| | | ]; |
| | | |
| | | if (!validEndpoints.includes(endpoint)) { |
| | |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | '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(); |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbReferral = new Referral(); |
| | | } |
| | | }); |
| | | }); |