Jake Vanderwerf
5 days ago a9b3b28d001941921aa70d37fdc87c758a163a44
assets/js/concise/Referral.js
@@ -5,7 +5,7 @@
class Referral {
   constructor() {
      this.container = document.querySelector('.jvb-referral');
      this.container = document.querySelector('aside.referral');
      if (!this.container) {
         return;
      }
@@ -13,15 +13,11 @@
      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();
      // Load additional data for logged-in users
      if (this.isLoggedIn()) {
         this.loadStats();
         this.loadRecentReferrals();
      }
   }
   initElements() {
@@ -29,10 +25,17 @@
         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 = new window.jvbPopup({
      this.popup = window.jvbPopup.registerPopup({
         toggle: this.toggle,
         popup: this.container,
         name: 'Referral Box',
@@ -45,13 +48,74 @@
      });
      this.tabs = null;
      if (this.container.querySelector('nav.tabs')) {
         this.tabs = new window.jvbTabs(this.container, {updateURL: false});
         this.tabs = window.jvbTabs.registerTab(this.container, {updateURL: false});
      }
      this.ui = window.uiFromSelectors(this.selectors, this.container);
      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: true,
               filters: {
                  type: 'dashboard',
                  user: window.auth.getUser()
               }
            },
            // Referrals list store
            {
               storeName: 'list',
               keyPath: 'id',
               endpoint: 'referrals',
               TTL: 10 * 60 * 1000,
               showLoading: false,
               delayFetch: true,
               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);
@@ -70,9 +134,76 @@
   }
   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;
      }
   }
   handleClick(e) {
      const target = e.target.closest('.copy-btn, .check-code-btn, .attn');
      if (!target) return;
@@ -98,71 +229,205 @@
      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(() => {
            // Fallback to selection
            this.selectText(codeElement);
            this.showCopyFallback(button);
            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 {
         // Fallback to selection
         this.selectText(codeElement);
         this.showCopyFallback(button);
         // 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;
      }
   }
   /**
    * Select text in element
    * Show error for specific field
    */
   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();
   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);
      }
   }
   /**
    * Show copy success feedback
    * Clear all form errors
    */
   showCopySuccess(button) {
      const originalHTML = button.innerHTML;
      button.innerHTML = window.jvbIcon('check', {size: 16}) + ' Copied!';
      button.classList.add('success');
   clearFormErrors(form) {
      // Clear field-level errors
      form.querySelectorAll('.field.has-error, .field.has-success').forEach(fieldWrapper => {
         this.clearFieldValidation(fieldWrapper);
      });
      setTimeout(() => {
         button.innerHTML = originalHTML;
         button.classList.remove('success');
      }, 2000);
      // Hide form status
      const statusWrap = form.querySelector('.fstatus');
      if (statusWrap) {
         statusWrap.hidden = true;
      }
   }
   /**
    * Show fallback message
    */
   showCopyFallback(button) {
      const originalHTML = button.innerHTML;
      button.innerHTML = '✓ Selected - Press Ctrl+C';
      button.classList.add('selected');
   clearFieldValidation(fieldWrapper) {
      if (!fieldWrapper) return;
      setTimeout(() => {
         button.innerHTML = originalHTML;
         button.classList.remove('selected');
      }, 3000);
      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);
      }
   }
   /**
@@ -226,15 +491,19 @@
    * 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;
      }
@@ -248,7 +517,21 @@
      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 {
@@ -264,9 +547,9 @@
               );
            }
            // 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 {
@@ -280,6 +563,8 @@
      // Clean up URL
      this.removeUrlParameter('ref');
      this.removeUrlParameter('rname');
      this.removeUrlParameter('remail');
   }
   getUrlParameter(name) {
@@ -297,11 +582,11 @@
    * 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 })
      });
@@ -310,105 +595,25 @@
   }
   /**
    * 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=${jvbSettings.currentUser}`, {
            headers: { 'X-WP-Nonce': jvbSettings.nonce }
         });
         const data = await response.json();
         if (data.success && data.stats) {
            this.updateStats(data.stats);
         }
      } catch (error) {
         console.error('Error loading 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);
      }
   }
   /**
    * 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;
   }
   /**
    * 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' });
   }
   /**
@@ -420,39 +625,54 @@
      const form = event.target;
      const formData = new FormData(form);
      // Disable form
      // Clear any existing errors
      this.clearFormErrors(form);
      this.setFormLoading(true, form);
      try {
         let result = { success: false, message: '' };
         if (form.id === 'referral-code-form') {
            const data = {
            // Registration with referral code - goes to LoginRoutes
            let 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) {
            const turnstileInput = form.querySelector('input[name="cf-turnstile-response"]');
            if (turnstileInput && turnstileInput.value) {
               data['cf-turnstile-response'] = turnstileInput.value;
            }
            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 = {
            let data = {
               type: 'login',
               email: formData.get('login_email'),
               user_email: formData.get('login_email'),
               context: {
                  redirect_to: window.location.href + '?seeReferral=1'
               }
            };
            result = await this.makeRequest('magic', data);
            const turnstileInput = form.querySelector('input[name="cf-turnstile-response"]');
            if (turnstileInput && turnstileInput.value) {
               data['cf-turnstile-response'] = turnstileInput.value;
            }
            if (!data['user_email']) {
               result.message = 'Please fill in your email';
            } else {
               result = await this.makeRequest('auth/magic', data);
            }
         }
         if (result.success) {
            this.handleSuccess(form, result);
         } else {
            this.showFormMessage(form, result.message || 'Something went wrong. Please try again.', 'error');
            this.handleError(form, result);
         }
      } catch (error) {
         console.error('Error submitting form:', error);
@@ -464,24 +684,29 @@
   async makeRequest(endpoint, data) {
      const validEndpoints = [
         'magic',
         'referrals/register',
         'referrals/check-code'
         'auth/magic',
         'auth/register'
      ];
      if (!validEndpoints.includes(endpoint)) {
         return { success: false, message: 'Invalid endpoint' };
      }
      const response = await fetch(`${jvbSettings.api}${endpoint}`, {
      const response = await window.auth.fetch(`${jvbSettings.api}${endpoint}`, {
         method: 'POST',
         headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': jvbSettings.nonce,
         },
         body: JSON.stringify(data)
      });
      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();
   }
@@ -536,20 +761,11 @@
    * Set form loading state
    */
   setFormLoading(loading, form) {
      const inputs = form.querySelectorAll('input, button');
      const inputs = form.querySelectorAll('input, button, textarea, select');
      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...';
            }
         }
      if (loading) {
         this.showFormStatus(form, 'saving');
      }
   }
@@ -563,8 +779,14 @@
      });
      this.container.dispatchEvent(event);
   }
}
document.addEventListener('DOMContentLoaded', () => {
   window.jvbReferral = new Referral();
document.addEventListener('DOMContentLoaded', async function () {
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {
         window.jvbReferral = new Referral();
      }
   });
});