From 25be5747a6e462a3d09fc6607b3639b79e4d9374 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 23 Dec 2025 20:11:26 +0000
Subject: [PATCH] =EmailManager.php refactor, Turnstile properly integrated with form submissions

---
 assets/js/concise/Referral.js |  566 +++++++++++++++++++++++++++++++++++++++-----------------
 1 files changed, 396 insertions(+), 170 deletions(-)

diff --git a/assets/js/concise/Referral.js b/assets/js/concise/Referral.js
index 9ec7702..6435e98 100644
--- a/assets/js/concise/Referral.js
+++ b/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,6 +25,13 @@
 			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');
@@ -45,13 +48,75 @@
 		});
 
 		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);
+
+
+
+		if (!this.hasCopy) {
+			document.querySelectorAll(this.selectors.copyBtn).forEach(btn => {
+				btn.remove();
+			});
+		}
+		this.formController = null;
 	}
 
+	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);
@@ -70,9 +135,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 +230,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 +492,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 +518,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 +548,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 +564,8 @@
 
 		// Clean up URL
 		this.removeUrlParameter('ref');
+		this.removeUrlParameter('rname');
+		this.removeUrlParameter('remail');
 	}
 
 	getUrlParameter(name) {
@@ -297,11 +583,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 +596,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 +626,52 @@
 		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) {
+				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('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'),
 					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);
@@ -465,8 +684,7 @@
 	async makeRequest(endpoint, data) {
 		const validEndpoints = [
 			'magic',
-			'referrals/register',
-			'referrals/check-code'
+			'auth/register'
 		];
 
 		if (!validEndpoints.includes(endpoint)) {
@@ -477,11 +695,22 @@
 			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();
 	}
 
@@ -536,20 +765,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 +783,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();
+		}
+	});
 });

--
Gitblit v1.10.0