1/** 2 * Mercurial Dashboard JavaScript 3 * 4 * Handles interactive features: 5 * - Tab switching 6 * - Copy license key 7 * - Site deactivation (admin only) 8 * - Inline Stripe payment form (2026-01-23) 9 * - Delete payment method (2026-01-23) 10 * 11 * @since 2026-01-23 12 */ 13 14(function () { 15 'use strict'; 16 17 const SELECTORS = { 18 dashboard: '.mercurial-dashboard', 19 tab: '[data-mercurial-action="switch-tab"]', 20 panel: '.mercurial-tab-panel', 21 copyBtn: '[data-mercurial-action="copy-key"]', 22 deactivateBtn: '[data-mercurial-action="deactivate-site"]', 23 siteCard: '.mercurial-site-card', 24 activationsCount: '.mercurial-activations-count', 25 activationsBar: '.mercurial-activations-bar-fill', 26 addPaymentForm: '#mercurial-add-payment-form', 27 stripeElement: '#mercurial-stripe-element', 28 stripeErrors: '#mercurial-stripe-errors', 29 submitPayment: '#mercurial-submit-payment', 30 paymentTable: '.mercurial-payment-table tbody', 31 }; 32 33 // Stripe state 34 let stripeInstance = null; 35 let stripeElements = null; 36 let cardElement = null; 37 let stripeParams = null; 38 39 function init() { 40 const dashboard = document.querySelector(SELECTORS.dashboard); 41 if (!dashboard) return; 42 43 dashboard.addEventListener('click', handleClick); 44 } 45 46 function handleClick(event) { 47 const target = event.target.closest('[data-mercurial-action]'); 48 if (!target) return; 49 50 const action = target.dataset.mercurialAction; 51 52 // Tracking is isolated: a failure here must never break the dashboard. 53 try { 54 trackAction(target, action); 55 } catch (e) {} 56 57 switch (action) { 58 case 'switch-tab': 59 switchTab(target); 60 break; 61 case 'copy-key': 62 copyKey(target); 63 break; 64 case 'deactivate-site': 65 event.preventDefault(); 66 deactivateSite(target); 67 break; 68 case 'toggle-add-payment': 69 event.preventDefault(); 70 toggleAddPaymentForm(); 71 break; 72 case 'close-add-payment': 73 event.preventDefault(); 74 closeAddPaymentForm(); 75 break; 76 case 'submit-payment': 77 event.preventDefault(); 78 submitPaymentMethod(target); 79 break; 80 case 'delete-payment': 81 event.preventDefault(); 82 deletePaymentMethod(target); 83 break; 84 } 85 } 86 87 // GA4 tracking: map every dashboard action to an analytics event so 88 // post-purchase behaviour (activations, downloads, renewals) is measurable. 89 const TRACK_EVENTS = { 90 'switch-tab': { name: 'account_tab_view', param: t => ({ tab: t.dataset.mercurialTab }) }, 91 'copy-key': { name: 'copy_license_key' }, 92 'download': { name: 'download_plugin', param: t => ({ product: t.dataset.mercurialProduct, version: t.dataset.mercurialVersion }) }, 93 'download-invoice': { name: 'download_invoice', param: t => ({ order_id: t.dataset.mercurialOrderId }) }, 94 'view-subscription': { name: 'manage_subscription', param: t => ({ subscription_id: t.dataset.mercurialSubscriptionId }) }, 95 'reactivate': { name: 'reactivate_license', param: t => ({ subscription_id: t.dataset.mercurialSubscriptionId }) }, 96 'deactivate-site': { name: 'deactivate_site', param: t => ({ site_url: t.dataset.mercurialSiteUrl }) }, 97 'help-click': { name: 'help_click', param: t => ({ label: t.dataset.mercurialHelp }) }, 98 'browse-products': { name: 'browse_products', param: t => ({ source: t.dataset.mercurialSource }) }, 99 'submit-payment': { name: 'add_payment_info' }, 100 'delete-payment': { name: 'remove_payment_method', param: t => ({ payment_id: t.dataset.mercurialPaymentId }) }, 101 'save-account': { name: 'update_account' },
102 // toggle-add-payment / close-add-payment are UI state only, not tracked. 103 }; 104 105 function trackAction(target, action) { 106 const map = TRACK_EVENTS[action]; 107 if (!map) { 108 return; 109 } 110 111 // Sites report through Cloudflare Zaraz (edge-side GA4) or load gtag 112 // directly. Support both; no-op when neither is present. 113 const zaraz = (window.zaraz && typeof window.zaraz.track === 'function') ? window.zaraz : null; 114 const hasGtag = typeof window.gtag === 'function'; 115 if (!zaraz && !hasGtag) { 116 return; 117 } 118 119 const params = map.param ? map.param(target) : {}; 120 121 // Enrich with the license context from the enclosing card, when present. 122 const card = target.closest('.mercurial-license-card'); 123 if (card && card.dataset.mercurialLicenseId) { 124 params.license_id = card.dataset.mercurialLicenseId; 125 } 126 127 // Drop empty values so GA4 does not record blank parameters. 128 Object.keys(params).forEach(key => { 129 if (params[key] === undefined || params[key] === '') { 130 delete params[key]; 131 } 132 }); 133 134 // Prefer Zaraz (zaraz.track -> "All Tracks" trigger -> GA4); else gtag. 135 if (zaraz) { 136 zaraz.track(map.name, params); 137 } else { 138 window.gtag('event', map.name, params); 139 } 140 } 141 142 function switchTab(tabButton) { 143 const tabName = tabButton.dataset.mercurialTab; 144 145 // Update tab buttons 146 document.querySelectorAll(SELECTORS.tab).forEach(tab => { 147 tab.classList.toggle('is-active', tab.dataset.mercurialTab === tabName); 148 }); 149 150 // Update panels 151 document.querySelectorAll(SELECTORS.panel).forEach(panel => { 152 const isTarget = panel.id === `mercurial-panel-${tabName}`; 153 panel.hidden = !isTarget; 154 }); 155 156 // Update URL without reload 157 const url = new URL(window.location); 158 url.searchParams.set('tab', tabName); 159 window.history.replaceState({}, '', url); 160 } 161 162 function copyKey(button) { 163 const targetSelector = button.dataset.mercurialTarget; 164 const keyElement = document.querySelector(targetSelector); 165 166 if (!keyElement) return; 167 168 const text = keyElement.textContent.trim(); 169 170 navigator.clipboard.writeText(text).then(() => { 171 const originalText = button.innerHTML; 172 button.classList.add('is-copied'); 173 button.innerHTML = ` 174 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 175 <polyline points="20 6 9 17 4 12"></polyline> 176 </svg> 177 Copied! 178 `; 179 180 setTimeout(() => { 181 button.classList.remove('is-copied'); 182 button.innerHTML = originalText; 183 }, 2000); 184 }); 185 } 186 187 async function deactivateSite(button) { 188 const instance = button.dataset.mercurialInstance; 189 const siteUrl = button.dataset.mercurialSiteUrl; 190 191 if (!instance) { 192 console.error('Missing instance ID'); 193 return; 194 } 195 196 // State: Deactivating... 197 button.disabled = true; 198 button.textContent = 'Deactivating...'; 199 button.classList.add('is-loading'); 200 201 try { 202 const response = await fetch('/wp-json/mercurial/v1/deactivate-site', { 203 method: 'POST', 204 headers: { 205 'Content-Type': 'application/json', 206 'X-WP-Nonce': mercurialDashboard.nonce, 207 }, 208 body: JSON.stringify({ instance }), 209 }); 210 211 const data = await response.json(); 212 213 if (data.success) { 214 // State: Deactivated! 215 button.textContent = 'Deactivated!'; 216 button.classList.remove('is-loading'); 217 button.classList.add('is-success'); 218 219 const siteCard = button.closest(SELECTORS.siteCard); 220 if (siteCard) { 221 // Store reference before card removal 222 const licenseCard = siteCard.closest('.mercurial-license-card'); 223 224 setTimeout(() => { 225 siteCard.style.opacity = '0'; 226 siteCard.style.transform = 'translateX(-20px)'; 227 228 setTimeout(() => { 229 siteCard.remove(); 230 updateActivationsCountForCard(licenseCard); 231 }, 300); 232 }, 800); 233 } 234 } else { 235 alert(data.message || 'Failed to deactivate site.'); 236 button.disabled = false;
237 button.textContent = 'Deactivate'; 238 button.classList.remove('is-loading'); 239 } 240 } catch (error) { 241 console.error('Deactivation error:', error); 242 alert('An error occurred. Please try again.'); 243 button.disabled = false; 244 button.textContent = 'Deactivate'; 245 button.classList.remove('is-loading'); 246 } 247 } 248 249 function updateActivationsCountForCard(licenseCard) { 250 if (!licenseCard) return; 251 252 const countEl = licenseCard.querySelector(SELECTORS.activationsCount); 253 const barEl = licenseCard.querySelector(SELECTORS.activationsBar); 254 255 if (!countEl) return; 256 257 const total = parseInt(countEl.dataset.mercurialTotal, 10); 258 const currentSites = licenseCard.querySelectorAll(SELECTORS.siteCard).length; 259 260 // Update text 261 countEl.innerHTML = `<strong>${currentSites}</strong> of <strong>${total}</strong> activations used`; 262 263 // Update progress bar 264 if (barEl) { 265 const percentage = total > 0 ? (currentSites / total) * 100 : 0; 266 barEl.style.width = `${percentage}%`; 267 } 268 269 // Update empty slots message 270 const siteList = licenseCard.querySelector('.mercurial-site-list'); 271 let emptySlot = siteList.querySelector('.mercurial-empty-slot'); 272 const emptySlots = total - currentSites; 273 274 if (emptySlots > 0) { 275 if (!emptySlot) { 276 emptySlot = document.createElement('div'); 277 emptySlot.className = 'mercurial-empty-slot'; 278 siteList.appendChild(emptySlot); 279 } 280 emptySlot.textContent = `+ ${emptySlots} activation slot${emptySlots > 1 ? 's' : ''} available`; 281 } 282 } 283 284 // ========================================================================= 285 // INLINE STRIPE PAYMENT FORM (2026-01-23) 286 // ========================================================================= 287 288 async function toggleAddPaymentForm() { 289 const form = document.querySelector(SELECTORS.addPaymentForm); 290 if (!form) return; 291 292 if (form.hidden) { 293 form.hidden = false; 294 form.classList.add('is-opening'); 295 296 // Initialize Stripe if not already done 297 if (!stripeInstance) { 298 await initializeStripe(); 299 } 300 301 setTimeout(() => form.classList.remove('is-opening'), 300); 302 } else { 303 closeAddPaymentForm(); 304 } 305 } 306 307 function closeAddPaymentForm() { 308 const form = document.querySelector(SELECTORS.addPaymentForm); 309 if (!form) return; 310 311 form.classList.add('is-closing'); 312 setTimeout(() => { 313 form.hidden = true; 314 form.classList.remove('is-closing'); 315 clearStripeErrors(); 316 }, 200); 317 } 318 319 async function initializeStripe() { 320 const elementContainer = document.querySelector(SELECTORS.stripeElement); 321 if (!elementContainer) return; 322 323 // Show loading state 324 elementContainer.innerHTML = '<div class="mercurial-stripe-loading">Loading payment form...</div>'; 325 326 try { 327 // Fetch Stripe params from our REST endpoint 328 const response = await fetch('/wp-json/mercurial/v1/stripe-params', { 329 headers: { 330 'X-WP-Nonce': mercurialDashboard.nonce, 331 }, 332 }); 333 334 const result = await response.json(); 335 336 if (!result.success) { 337 throw new Error(result.message || 'Failed to load payment form'); 338 } 339 340 stripeParams = result.data; 341 342 // Load Stripe.js if not already loaded 343 if (typeof Stripe === 'undefined') { 344 await loadStripeScript(); 345 } 346 347 // Initialize Stripe 348 stripeInstance = Stripe(stripeParams.publishableKey, { 349 locale: stripeParams.locale || 'auto', 350 }); 351 352 // Create Elements with light theme matching Mercurial Dashboard 353 stripeElements = stripeInstance.elements({ 354 appearance: { 355 theme: 'stripe', 356 variables: { 357 colorPrimary: '#84cc16',
358 colorBackground: '#ffffff', 359 colorText: '#1a212b', 360 colorDanger: '#dc2626', 361 fontFamily: 'Inter, system-ui, sans-serif', 362 spacingUnit: '4px', 363 borderRadius: '6px', 364 }, 365 rules: { 366 '.Input': { 367 backgroundColor: '#ffffff', 368 border: '1px solid #dde2e7', 369 }, 370 '.Input:focus': { 371 border: '1px solid #84cc16', 372 boxShadow: '0 0 0 1px #84cc16', 373 }, 374 '.Label': { 375 color: '#415966', 376 }, 377 }, 378 }, 379 }); 380 381 // Clear container and create Card Element 382 elementContainer.innerHTML = ''; 383 cardElement = stripeElements.create('card', { 384 hidePostalCode: true, 385 style: { 386 base: { 387 fontSize: '16px', 388 color: '#1a212b', 389 '::placeholder': { 390 color: '#8a97a4', 391 }, 392 }, 393 invalid: { 394 color: '#dc2626', 395 }, 396 }, 397 }); 398 399 cardElement.mount(elementContainer); 400 401 // Handle card errors 402 cardElement.on('change', (event) => { 403 if (event.error) { 404 showStripeError(event.error.message); 405 } else { 406 clearStripeErrors(); 407 } 408 }); 409 410 } catch (error) { 411 console.error('Stripe initialization error:', error); 412 elementContainer.innerHTML = `<div class="mercurial-stripe-error">Failed to load payment form. Please refresh the page.</div>`; 413 } 414 } 415 416 function loadStripeScript() { 417 return new Promise((resolve, reject) => { 418 if (typeof Stripe !== 'undefined') { 419 resolve(); 420 return; 421 } 422 423 const script = document.createElement('script'); 424 script.src = 'https://js.stripe.com/v3/'; 425 script.async = true; 426 script.onload = resolve; 427 script.onerror = () => reject(new Error('Failed to load Stripe.js')); 428 document.head.appendChild(script); 429 }); 430 } 431 432 function showStripeError(message) { 433 const errorsEl = document.querySelector(SELECTORS.stripeErrors); 434 if (errorsEl) { 435 errorsEl.textContent = message; 436 errorsEl.hidden = false; 437 } 438 } 439 440 function clearStripeErrors() { 441 const errorsEl = document.querySelector(SELECTORS.stripeErrors); 442 if (errorsEl) { 443 errorsEl.textContent = ''; 444 errorsEl.hidden = true; 445 } 446 } 447 448 async function submitPaymentMethod(button) { 449 if (!stripeInstance || !cardElement || !stripeParams) { 450 showStripeError('Payment form not initialized. Please try again.'); 451 return; 452 } 453 454 setButtonLoading(button, true); 455 clearStripeErrors(); 456 457 try { 458 const { paymentMethod, error: pmError } = await stripeInstance.createPaymentMethod({ 459 type: 'card', 460 card: cardElement, 461 }); 462 463 if (pmError) { 464 throw new Error(pmError.message); 465 } 466 467 const formData = new FormData(); 468 formData.append('action', 'wc_stripe_create_and_confirm_setup_intent'); 469 formData.append('_ajax_nonce', stripeParams.nonce); 470 formData.append('wc-stripe-payment-method', paymentMethod.id); 471 formData.append('wc-stripe-payment-type', 'card'); 472 473 const updateSubsCheckbox = document.getElementById('mercurial-update-subscriptions'); 474 if (updateSubsCheckbox && updateSubsCheckbox.checked) { 475 formData.append('update_all_subscription_payment_methods', '1'); 476 } 477 478 const response = await fetch(stripeParams.ajaxUrl, { 479 method: 'POST', 480 body: formData, 481 }); 482 483 const result = await response.json(); 484 485 if (!result.success) { 486 throw new Error(result.data?.error?.message || 'Failed to save payment method'); 487 } 488 489 if (result.data.status === 'requires_action' && result.data.client_secret) { 490 const { error: confirmError }
490 = await stripeInstance.confirmCardSetup( 491 result.data.client_secret 492 ); 493 494 if (confirmError) { 495 throw new Error(confirmError.message); 496 } 497 } 498 499 if (updateSubsCheckbox && updateSubsCheckbox.checked) { 500 await fetch('/wp-json/mercurial/v1/update-subscriptions-payment', { 501 method: 'POST', 502 headers: { 503 'Content-Type': 'application/json', 504 'X-WP-Nonce': stripeParams.restNonce, 505 }, 506 body: JSON.stringify({ payment_method_id: paymentMethod.id }), 507 }); 508 } 509 510 setButtonLoading(button, false); 511 closeAddPaymentForm(); 512 showSuccessMessage('Card added successfully!'); 513 setTimeout(() => { 514 window.location.reload(); 515 }, 1500); 516 517 } catch (error) { 518 showStripeError(error.message || 'An error occurred. Please try again.'); 519 setButtonLoading(button, false); 520 } 521 } 522 523 function setButtonLoading(button, loading) { 524 const textEl = button.querySelector('.mercurial-btn-text'); 525 const spinnerEl = button.querySelector('.mercurial-btn-spinner'); 526 527 if (loading) { 528 button.disabled = true; 529 if (textEl) textEl.hidden = true; 530 if (spinnerEl) spinnerEl.hidden = false; 531 } else { 532 button.disabled = false; 533 if (textEl) textEl.hidden = false; 534 if (spinnerEl) spinnerEl.hidden = true; 535 } 536 } 537 538 function showSuccessMessage(message) { 539 const form = document.querySelector(SELECTORS.addPaymentForm); 540 if (!form) return; 541 542 const successEl = document.createElement('div'); 543 successEl.className = 'mercurial-success-message'; 544 successEl.innerHTML = ` 545 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"> 546 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> 547 <polyline points="22 4 12 14.01 9 11.01"></polyline> 548 </svg> 549 <span>${message}</span> 550 `; 551 form.querySelector('.mercurial-add-payment-body').innerHTML = ''; 552 form.querySelector('.mercurial-add-payment-body').appendChild(successEl); 553 form.querySelector('.mercurial-add-payment-footer').hidden = true; 554 } 555 556 // ========================================================================= 557 // DELETE PAYMENT METHOD (2026-01-23) 558 // ========================================================================= 559 560 async function deletePaymentMethod(button) { 561 const paymentId = button.dataset.mercurialPaymentId; 562 563 if (!paymentId) { 564 console.error('Missing payment ID'); 565 return; 566 } 567 568 // [Delete] â [Deleting...] 569 button.disabled = true; 570 button.textContent = 'Deleting...'; 571 button.classList.add('is-loading'); 572 573 try { 574 const response = await fetch('/wp-json/mercurial/v1/delete-payment-method', { 575 method: 'POST', 576 headers: { 577 'Content-Type': 'application/json', 578 'X-WP-Nonce': mercurialDashboard.nonce, 579 }, 580 body: JSON.stringify({ token_id: parseInt(paymentId, 10) }), 581 }); 582 583 const data = await response.json(); 584 585 if (data.success) { 586 // [Deleting...] â [Deleted!] 587 button.textContent = 'Deleted!'; 588 button.classList.remove('is-loading'); 589 button.classList.add('is-deleted'); 590 591 // Animate row removal after brief delay 592 const row = button.closest('tr'); 593 if (row) { 594 setTimeout(() => { 595 row.style.transition = 'opacity 0.3s, transform 0.3s'; 596 row.style.opacity = '0'; 597 row.style.transform = 'translateX(-20px)'; 598 599 setTimeout(() => { 600 row.remove(); 601 checkEmptyTable(); 602 }, 300); 603 }, 500); 604 } 605 } else { 606 // Reset on failure 607 button.disabled = false;
608 button.textContent = 'Delete'; 609 button.classList.remove('is-loading'); 610 } 611 } catch (error) { 612 console.error('Delete payment error:', error); 613 button.disabled = false; 614 button.textContent = 'Delete'; 615 button.classList.remove('is-loading'); 616 } 617 } 618 619 function checkEmptyTable() { 620 const tbody = document.querySelector(SELECTORS.paymentTable); 621 if (!tbody) return; 622 623 const rows = tbody.querySelectorAll('tr'); 624 if (rows.length === 0) { 625 // Replace table with empty state 626 const table = tbody.closest('table'); 627 if (table) { 628 const emptyState = document.createElement('p'); 629 emptyState.className = 'mercurial-empty-state'; 630 emptyState.textContent = 'No payment methods saved.'; 631 table.parentNode.replaceChild(emptyState, table); 632 } 633 } 634 } 635 636 // Initialize when DOM is ready 637 if (document.readyState === 'loading') { 638 document.addEventListener('DOMContentLoaded', init); 639 } else { 640 init(); 641 } 642})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.