/**
|
* 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 = '<span class="spinner"></span> 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 = '<p class="no-referrals">Share your code to get started!</p>';
|
return;
|
}
|
|
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.referral_status}</span>
|
</div>
|
<div class="referral-date">${window.formatTimeAgo(ref.referred_at)}</div>
|
</div>
|
`).join('');
|
}
|
|
/**
|
* Handle form submission
|
*/
|
async handleFormSubmit(event) {
|
event.preventDefault();
|
|
const form = event.target;
|
const formData = new FormData(form);
|
|
// Clear any existing errors
|
this.clearFormErrors(form);
|
this.setFormLoading(true, form);
|
|
try {
|
let result = { success: false, message: '' };
|
|
if (form.id === 'referral-code-form') {
|
// Registration with referral code - goes to LoginRoutes
|
let data = {
|
name: formData.get('referral_name'),
|
email: formData.get('referral_email'),
|
referral_code: formData.get('referral_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('auth/register', data); // UPDATED endpoint
|
}
|
} else if (form.id === 'login-form') {
|
let data = {
|
type: 'login',
|
user_email: formData.get('login_email'),
|
context: {
|
redirect_to: window.location.href + '?seeReferral=1'
|
}
|
};
|
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.handleError(form, result);
|
}
|
} 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 = [
|
'auth/magic',
|
'auth/register'
|
];
|
|
if (!validEndpoints.includes(endpoint)) {
|
return { success: false, message: 'Invalid endpoint' };
|
}
|
|
const response = await window.auth.fetch(`${jvbSettings.api}${endpoint}`, {
|
method: 'POST',
|
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();
|
}
|
|
/**
|
* 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, textarea, select');
|
inputs.forEach(input => input.disabled = loading);
|
|
if (loading) {
|
this.showFormStatus(form, 'saving');
|
}
|
}
|
|
/**
|
* 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();
|
}
|
});
|
});
|