| | |
| | | |
| | | this.hasCopy = navigator.clipboard && navigator.clipboard.writeText; |
| | | this.initElements(); |
| | | this.storesInited = false; |
| | | this.initStore(); |
| | | this.initListeners(); |
| | | this.checkForReferral(); |
| | |
| | | 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.tabs = window.jvbTabs.registerTab(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.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); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | |
| | | // 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); |
| | | button.classList.toggle('success'); |
| | | setTimeout(() => { |
| | | button.classList.remove('success'); |
| | | }, 1500); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Select text in element |
| | | * Handle error response with field-specific feedback |
| | | */ |
| | | 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(); |
| | | 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 copy success feedback |
| | | * Show error for specific field |
| | | */ |
| | | showCopySuccess(button) { |
| | | const originalHTML = button.innerHTML; |
| | | button.innerHTML = window.jvbIcon('check', {size: 16}) + ' Copied!'; |
| | | button.classList.add('success'); |
| | | 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}"]`); |
| | | } |
| | | |
| | | setTimeout(() => { |
| | | button.innerHTML = originalHTML; |
| | | button.classList.remove('success'); |
| | | }, 2000); |
| | | 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 fallback message |
| | | * Clear all form errors |
| | | */ |
| | | showCopyFallback(button) { |
| | | const originalHTML = button.innerHTML; |
| | | button.innerHTML = '✓ Selected - Press Ctrl+C'; |
| | | button.classList.add('selected'); |
| | | 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('selected'); |
| | | }, 3000); |
| | | // 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); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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() { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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) { |
| | |
| | | const form = event.target; |
| | | const formData = new FormData(form); |
| | | |
| | | // Clear any existing errors |
| | | this.clearFormErrors(form); |
| | | this.setFormLoading(true, form); |
| | | |
| | | try { |
| | |
| | | |
| | | if (form.id === 'referral-code-form') { |
| | | // Registration with referral code - goes to LoginRoutes |
| | | const data = { |
| | | let data = { |
| | | name: formData.get('referral_name'), |
| | | email: formData.get('referral_email'), |
| | | referral_code: formData.get('referral_code') |
| | | }; |
| | | |
| | | if (formData.get('cf-turnstile-response')) { |
| | | data['cf-turnstile-response'] = formData.get('cf-turnstile-response'); |
| | | } |
| | | |
| | | 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 = { |
| | | let data = { |
| | | type: 'login', |
| | | email: formData.get('login_email'), |
| | | context: { |
| | | redirect_to: window.location.href + '?seeReferral=1' |
| | | } |
| | | }; |
| | | result = await this.makeRequest('magic', data); |
| | | if (formData.get('cf-turnstile-response')) { |
| | | data['cf-turnstile-response'] = formData.get('cf-turnstile-response'); |
| | | } |
| | | if (!data.email) { |
| | | result.message = 'Please fill in your email'; |
| | | } else { |
| | | 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'); |
| | | this.handleError(form, result); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error submitting form:', error); |
| | |
| | | * 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'); |
| | | } |
| | | } |
| | | |
| | |
| | | }); |
| | | this.container.dispatchEvent(event); |
| | | } |
| | | |
| | | |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', async function () { |