/**
|
* 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 = '<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();
|
}
|
|
/**
|
* 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 = '<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('');
|
}
|
|
/**
|
* 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();
|
}
|
});
|
});
|