PluginProbe
wpForo Forum / 3.1.7
wpForo Forum v3.1.7
3.1.7 3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 All 139 releases
wpforo / admin / pages / license / admin / assets / js / admin.js

admin.js in wpForo Forum 3.1.7, at admin/pages/license/admin/assets/js/admin.js

1,533 lines 58.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * gVectors License Module - Admin JavaScript
3 * Handles product listing, centralized checkout (new tab), license management, addon install/activate
4 */
5 (function ($) {
6 'use strict';
7
8 let gVectorsLicense = {
9 products: [],
10 _pollingActive: false,
11 _checkoutOptions: null,
12
13 init: function () {
14 var $wrap = $('#gvlicense-admin-wrap');
15 var slug = $wrap.data('slug') || 'gvectors';
16 this.config = window[slug + 'License'] || {};
17 this.ajaxPrefix = this.config.ajaxPrefix || 'gvectors_';
18
19 this.bindEvents();
20 this.loadProducts();
21 this.loadLicenses();
22 this.resumePendingTransactions();
23 },
24
25 // ==========================================
26 // Visibility-based polling
27 // ==========================================
28 bindVisibilityPolling: function () {
29 const self = this;
30 document.addEventListener('visibilitychange', function(){
31 self.startPolling( self );
32 });
33 },
34
35 startPolling: function( s ){
36 const self = s || this;
37 if (document.visibilityState === 'visible') {
38 if (self._currentTransactionId && !self._pollingActive) {
39 self.pollTransactionStatus(self._currentTransactionId);
40 }
41 } else {
42 self._pollingActive = false;
43 }
44 },
45
46 /**
47 * Poll the proxy server to check if the webhook has arrived and a license was created.
48 */
49 pollTransactionStatus: function (transactionId, attempt) {
50 const self = this;
51 attempt = attempt || 1;
52 const maxAttempts = 15;
53 const interval = 2000; // 2 seconds
54
55 this._pollingActive = true;
56 this.showToast(this.config.i18n.processing, 'info');
57
58 this.ajax('verify_transaction', {
59 transaction_id: transactionId,
60 }, function (response) {
61 if (response.success && response.data) {
62 const status = response.data.status;
63
64 if (status === 'completed') {
65 self._pollingActive = false;
66 self.removeCheckoutOverlay();
67 self.clearPendingTransaction(transactionId);
68 self._currentTransactionId = null;
69
70 // All post-purchase actions (subscription cancellation, domain deactivation)
71 // are handled server-side by the webhook via checkout_options
72 self._checkoutOptions = null;
73
74 // Bundle: multiple licenses
75 if (response.data.is_bundle && response.data.licenses && response.data.licenses.length) {
76 self.activateBundleLicenses(response.data.licenses);
77 return;
78 }
79 // Single license
80 if (response.data.license) {
81 const license = response.data.license;
82 self.activateLicenseAfterPurchase(license.license_key, license.product_id);
83 return;
84 }
85 }
86
87 if (status === 'duplicate') {
88 self._pollingActive = false;
89 self.removeCheckoutOverlay();
90 self.clearPendingTransaction(transactionId);
91 self._currentTransactionId = null;
92 self.showToast(response.data.message || 'License already active.', 'info');
93 self.loadProducts();
94 self.loadLicenses();
95 return;
96 }
97
98 // Still pending — retry if tab is visible
99 if (attempt < maxAttempts && self._pollingActive) {
100 setTimeout(function () {
101 self.pollTransactionStatus(transactionId, attempt + 1);
102 }, interval);
103 } else {
104 self._pollingActive = false;
105 self.removeCheckoutOverlay();
106 self.showToast(self.config.i18n.purchasePending || 'Purchase completed! License will be activated shortly.', 'info');
107 self.loadProducts();
108 self.loadLicenses();
109 }
110 }
111 });
112 },
113
114 /**
115 * Save a pending transaction ID to the server so it persists across page reloads.
116 */
117 savePendingTransaction: function (transactionId) {
118 this.ajax('save_pending_transaction', {
119 transaction_id: transactionId,
120 }, function () {});
121 },
122
123 /**
124 * Clear a completed/resolved pending transaction from the server.
125 */
126 clearPendingTransaction: function (transactionId) {
127 this.ajax('clear_pending_transaction', {
128 transaction_id: transactionId,
129 }, function () {});
130 },
131
132 /**
133 * On a dashboard load, check for any pending transactions that were not
134 * completed (e.g., the page was closed/reloaded during checkout polling).
135 * Verify and activate them in the background.
136 */
137 resumePendingTransactions: function () {
138 const self = this;
139 this.ajax('get_pending_transactions', {}, function (response) {
140 if (response.success && response.data && response.data.length) {
141 for (let i = 0; i < response.data.length; i++) {
142 self.resumeTransaction(response.data[i]);
143 }
144 }
145 });
146 },
147
148 /**
149 * Silently verify a single pending transaction and activate its license(s) if completed.
150 */
151 resumeTransaction: function (transactionId, attempt) {
152 const self = this;
153 attempt = attempt || 1;
154 const maxAttempts = 10;
155 const interval = 3000;
156
157 this.ajax('verify_transaction', {
158 transaction_id: transactionId,
159 }, function (response) {
160 if (response.success && response.data) {
161 const status = response.data.status;
162
163 if (status === 'completed') {
164 self.clearPendingTransaction(transactionId);
165 // Bundle
166 if (response.data.is_bundle && response.data.licenses && response.data.licenses.length) {
167 self.activateBundleLicenses(response.data.licenses);
168 return;
169 }
170 // Single
171 if (response.data.license) {
172 const license = response.data.license;
173 self.activateLicenseAfterPurchase(license.license_key, license.product_id);
174 return;
175 }
176 // Completed but no license data — just refresh
177 self.loadProducts();
178 self.loadLicenses();
179 return;
180 }
181
182 if (status === 'duplicate') {
183 self.clearPendingTransaction(transactionId);
184 self.loadProducts();
185 self.loadLicenses();
186 return;
187 }
188
189 // Still pending — retry silently
190 if (attempt < maxAttempts) {
191 setTimeout(function () {
192 self.resumeTransaction(transactionId, attempt + 1);
193 }, interval);
194 }
195 // After max attempts, leave it pending for next page load
196 }
197 });
198 },
199
200 // ==========================================
201 // Event Bindings
202 // ==========================================
203 bindEvents: function () {
204 const self = this;
205
206 // Refresh products
207 $(document).on('click', '#gvlicense-refresh', function () {
208 self.ajax('clear_cache', {}, function () {
209 self.loadProducts();
210 self.loadLicenses();
211 });
212 });
213
214 // Unified activate (license key, transaction ID, or auto-detect by domain)
215 $(document).on('click', '#gvlicense-unified-activate-btn', function () {
216 self.unifiedActivate();
217 });
218
219 // Buy button - show pre-checkout dialog (overlap check + confirmation)
220 $(document).on('click', '.gvlicense-buy-btn', function () {
221 const $btn = $(this);
222 const $card = $btn.closest('.gvlicense-product-card');
223 const productId = $btn.data('product-id');
224
225 // Read from select box if present, otherwise use button's data attribute
226 const $select = $card.find('.gvlicense-price-select');
227 const priceId = $select.length ? $select.val() : $btn.data('price-id');
228
229 if (!priceId) {
230 alert('Please select a price option for this product.');
231 return;
232 }
233
234 self.openCheckout(priceId, productId, $btn);
235 });
236
237 // Price select change — update detail display and buy button
238 $(document).on('change', '.gvlicense-price-select', function () {
239 const $select = $(this);
240 const $card = $select.closest('.gvlicense-product-card');
241 const $opt = $select.find('option:selected');
242 const $detail = $card.find('.gvlicense-price-detail');
243
244 // Update price detail display
245 $detail.find('.gvlicense-price-amount').text($opt.data('price') || '');
246 $detail.find('.gvlicense-price-interval').text($opt.data('interval') || '');
247
248 var name = $opt.data('name') || '';
249 var $nameEl = $detail.find('.gvlicense-price-name');
250 if (name) {
251 if ($nameEl.length) { $nameEl.text(name); }
252 else { $detail.append('<span class="gvlicense-price-name">' + $('<span>').text(name).html() + '</span>'); }
253 } else {
254 $nameEl.remove();
255 }
256
257 var desc = $opt.data('description') || '';
258 var $descEl = $detail.find('.gvlicense-price-desc');
259 if (desc) {
260 if ($descEl.length) { $descEl.text(desc); }
261 else { $detail.append('<span class="gvlicense-price-desc">' + $('<span>').text(desc).html() + '</span>'); }
262 } else {
263 $descEl.remove();
264 }
265
266 // Update buy button data attribute
267 $card.find('.gvlicense-buy-btn').data('price-id', $select.val());
268 });
269
270 // Start trial
271 $(document).on('click', '.gvlicense-trial-btn', function () {
272 const productId = $(this).data('product-id');
273 self.startTrial(productId, $(this));
274 });
275
276 // Install & Activate addon
277 $(document).on('click', '.gvlicense-install-btn', function () {
278 const productId = $(this).data('product-id');
279 self.installAndActivateAddon(productId, $(this));
280 });
281
282 // Activate addon (already installed)
283 $(document).on('click', '.gvlicense-activate-addon-btn', function () {
284 const productId = $(this).data('product-id');
285 self.installAndActivateAddon(productId, $(this));
286 });
287
288 // Manage button
289 $(document).on('click', '.gvlicense-manage-btn', function () {
290 const productId = $(this).data('product-id');
291 self.openManageModal(productId);
292 });
293
294 // Close modal
295 $(document).on('click', '.gvlicense-modal-close, .gvlicense-modal-overlay', function () {
296 $('#gvlicense-manage-modal').hide();
297 });
298
299 // Deactivate license (from account page or modal)
300 $(document).on('click', '.gvlicense-deactivate-btn', function () {
301 const productId = $(this).data('product-id');
302 self.deactivateLicense(productId, $(this));
303 });
304
305 // Validate all licenses (single batch button)
306 $(document).on('click', '.gvlicense-validate-all-btn', function () {
307 self.validateAllLicenses($(this));
308 });
309
310 // Subscription management
311 $(document).on('click', '.gvlicense-cancel-sub-btn', function () {
312 if (!confirm('Are you sure you want to cancel this subscription? It will remain active until the end of the current billing period.')) return;
313 const subId = $(this).data('subscription-id');
314 self.manageSubscription('cancel', subId, $(this));
315 });
316 $(document).on('click', '.gvlicense-resume-sub-btn', function () {
317 const subId = $(this).data('subscription-id');
318 self.manageSubscription('resume', subId, $(this));
319 });
320 // Resubscribe - close modal and trigger buy on the product card
321 $(document).on('click', '.gvlicense-resubscribe-btn', function () {
322 const productId = $(this).data('product-id');
323 $('#gvlicense-manage-modal').hide();
324 const $card = $('.gvlicense-product-card').filter(function () {
325 return $(this).find('.gvlicense-buy-btn[data-product-id="' + productId + '"]').length > 0;
326 });
327 if ($card.length) {
328 $card.find('.gvlicense-buy-btn').first().trigger('click');
329 } else {
330 self.showToast('Please use the Buy button on the product card to resubscribe.', 'info');
331 }
332 });
333 $(document).on('click', '.gvlicense-manage-paddle-btn', function () {
334 const $btn = $(this);
335 const subId = $btn.data('subscription-id');
336 self.setLoading($btn, true);
337 self.ajax('get_portal_url', { subscription_id: subId }, function (response) {
338 self.setLoading($btn, false);
339 if (response.success && response.data && response.data.portal_url) {
340 window.open(response.data.portal_url, '_blank');
341 } else {
342 const msg = (response.data && response.data.message) ? response.data.message : 'Failed to open Paddle portal.';
343 self.showToast(msg, 'error');
344 }
345 });
346 });
347 },
348
349 // ==========================================
350 // Products
351 // ==========================================
352 loadProducts: function () {
353 const self = this;
354 const $grid = $('#gvlicense-products-grid');
355
356 if (!$grid.length) return;
357
358 $grid.html('<div class="gvlicense-loading"><span class="spinner is-active" style="float:none;"></span> ' + this.config.i18n.loading + '</div>');
359
360 this.ajax('get_products', {}, function (response) {
361 if (response.success && response.data) {
362 const data = response.data;
363 const products = data.products || data;
364 const checkoutMode = data.checkout_mode || 'both';
365 self.products = products;
366 self.checkoutMode = checkoutMode;
367 self.renderProducts(products);
368 } else {
369 $grid.html('<div class="gvlicense-no-products">' + self.config.i18n.noProducts + '</div>');
370 }
371 });
372 },
373
374 renderProducts: function (products) {
375 const $grid = $('#gvlicense-products-grid');
376 $grid.empty();
377
378 if (!products.length) {
379 $grid.html('<div class="gvlicense-no-products">' + this.config.i18n.noProducts + '</div>');
380 return;
381 }
382
383 const template = wp.template('gvlicense-product-card');
384 for (let i = 0; i < products.length; i++) {
385 products[i].checkout_mode = this.checkoutMode || 'both';
386 $grid.append(template(products[i]));
387 }
388
389 // Populate the unified product select box
390 const $select = $('#gvlicense-unified-product');
391 if ($select.length) {
392 $select.find('option:not(:first)').remove();
393 for (let j = 0; j < products.length; j++) {
394 if (!products[j].is_bundle && products[j].id) {
395 $select.append('<option value="' + products[j].id + '">' + $('<span>').text(products[j].name).html() + '</option>');
396 }
397 }
398 }
399 },
400
401 // ==========================================
402 // Checkout Overlay
403 // ==========================================
404 showCheckoutOverlay: function (message) {
405 var self = this;
406 this.removeCheckoutOverlay();
407 const html = '<div id="gvlicense-checkout-overlay" style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:999999;display:flex;align-items:center;justify-content:center;">' +
408 '<div id="gvlicense-checkout-overlay-box" style="background:#fff;padding:40px 50px;border-radius:8px;text-align:center;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,0.3);position:relative;">' +
409 '<button type="button" id="gvlicense-checkout-overlay-close" style="position:absolute;top:8px;right:12px;background:none;border:none;font-size:22px;color:#999;cursor:pointer;line-height:1;padding:4px;">&times;</button>' +
410 '<span class="spinner is-active" style="float:none;margin:0 auto 15px;display:block;"></span>' +
411 '<p id="gvlicense-checkout-overlay-msg" style="font-size:15px;margin:0;color:#333;">' + message + '</p>' +
412 '</div></div>';
413 $('body').append(html);
414
415 // Close on X button click
416 $('#gvlicense-checkout-overlay-close').on('click', function () {
417 self.removeCheckoutOverlay();
418 self._pollingActive = false;
419 });
420
421 // Close on click outside the dialog box
422 $('#gvlicense-checkout-overlay').on('click', function (e) {
423 if (e.target === this) {
424 self.removeCheckoutOverlay();
425 self._pollingActive = false;
426 }
427 });
428 },
429
430 updateCheckoutOverlay: function (message) {
431 $('#gvlicense-checkout-overlay-msg').html(message);
432 },
433
434 removeCheckoutOverlay: function () {
435 $('#gvlicense-checkout-overlay').remove();
436 },
437
438 // ==========================================
439 // Checkout
440 // ==========================================
441
442 /**
443 * Pre-checkout flow: show dialog, check overlaps, let customer decide, then create transaction.
444 * No popup is opened until the customer clicks "Continue to Checkout" inside the dialog.
445 */
446 openCheckout: function (priceId, productId, $btn) {
447 const self = this;
448
449 this.setLoading($btn, true);
450
451 // Show dialog immediately with a loading state
452 $('#gvlicense-modal-title').text('Preparing Checkout');
453 $('#gvlicense-modal-body').html(
454 '<div style="text-align:center;padding:20px 0;">' +
455 '<span class="spinner is-active" style="float:none;margin:0 auto 10px;display:block;"></span>' +
456 '<p style="color:#666;margin:0;">Checking your subscription status...</p>' +
457 '</div>'
458 );
459 $('#gvlicense-manage-modal').show();
460
461 // Check for overlaps (no transaction created yet)
462 this.ajax('check_overlap', {
463 price_id: priceId,
464 product_id: productId,
465 }, function (response) {
466 self.setLoading($btn, false);
467
468 if (!response.success) {
469 $('#gvlicense-manage-modal').hide();
470 var msg = (response.data && response.data.message) ? response.data.message : self.config.i18n.checkoutError;
471 self.showToast(msg, 'error');
472 return;
473 }
474
475 var data = response.data || {};
476 var overlaps = data.overlapping_licenses || [];
477 var overlapType = data.overlap_type || 'none';
478
479 // Always show the modal — with or without overlaps
480 self.showPreCheckoutPrompt(overlapType, data.message || '', overlaps, function (checkoutOptions) {
481 self._checkoutOptions = checkoutOptions;
482 self.startCheckout(priceId, productId, $btn);
483 });
484 });
485 },
486
487 /**
488 * Show the pre-checkout modal with overlap details or clean confirmation.
489 */
490 showPreCheckoutPrompt: function (overlapType, message, overlaps, onProceed) {
491 var subscriptionChoices = {}; // keyed by subscription_id → { action, product_id, product_name, license_keys, price_id }
492 var html = '';
493
494 if (overlapType === 'none' || !overlaps.length) {
495 // No conflicts
496 $('#gvlicense-modal-title').text('Ready to Checkout');
497 html = '<div class="gvlicense-modal-section">' +
498 '<p style="margin:0 0 20px;color:#1e7e34;font-size:13px;line-height:1.5;">' +
499 'You are ready to proceed with your purchase.' +
500 '</p>' +
501 '<div style="display:flex;gap:10px;justify-content:flex-end;">' +
502 '<button type="button" class="button gvlicense-overlap-abort-btn">Cancel</button>' +
503 '<button type="button" class="button button-primary gvlicense-overlap-proceed-btn">Continue to Checkout</button>' +
504 '</div></div>';
505
506 $('#gvlicense-modal-body').html(html);
507 $('#gvlicense-manage-modal').off('click.overlap').on('click.overlap', '.gvlicense-overlap-proceed-btn', function () {
508 $('#gvlicense-manage-modal').off('click.overlap').hide();
509 if (typeof onProceed === 'function') onProceed(null);
510 }).on('click.overlap', '.gvlicense-overlap-abort-btn, .gvlicense-modal-close', function () {
511 $('#gvlicense-manage-modal').off('click.overlap').hide();
512 });
513 return;
514 }
515
516 // Has overlaps — show details
517 var titles = {
518 duplicate: 'Duplicate Subscription Detected',
519 downgrade: 'Existing Higher-Tier Subscription',
520 upgrade: 'Subscription Upgrade',
521 lifetime: 'Lifetime Purchase',
522 plan_change: 'Plan Change Detected'
523 };
524 $('#gvlicense-modal-title').text(titles[overlapType] || 'Subscription Conflict');
525
526 html = '<div class="gvlicense-modal-section">' +
527 '<p style="margin:0 0 15px;color:#555;font-size:13px;line-height:1.5;">' +
528 $('<span>').text(message).html() +
529 '</p>';
530
531 // Group by subscription_id to avoid showing duplicates
532 var seenSubs = {};
533 for (var i = 0; i < overlaps.length; i++) {
534 var lic = overlaps[i];
535 var subId = lic.subscription_id || '';
536 var groupKey = subId || ('lic_' + i);
537 if (seenSubs[groupKey]) {
538 // Add to existing group
539 seenSubs[groupKey].licenses.push(lic);
540 continue;
541 }
542 seenSubs[groupKey] = { subscription_id: subId, licenses: [lic] };
543 }
544
545 var siteDomain = this.config.siteDomain || '';
546
547 for (var key in seenSubs) {
548 var group = seenSubs[key];
549 var firstLic = group.licenses[0];
550 var subIdDisplay = group.subscription_id;
551 var isBundle = group.licenses.length > 1;
552 var groupName = isBundle ? (firstLic.product_name + ' (Bundle)') : firstLic.product_name;
553
554 html += '<div class="gvlicense-overlap-row" data-subscription-id="' + subIdDisplay + '" ' +
555 'style="background:#f9f9f9;border:1px solid #e5e5e5;border-radius:4px;padding:14px 16px;margin-bottom:12px;">';
556
557 html += '<div style="margin-bottom:10px;">' +
558 '<strong style="font-size:14px;">' + $('<span>').text(groupName).html() + '</strong>';
559 if (firstLic.plan_name) {
560 html += ' <span style="color:#888;font-size:12px;">(' + $('<span>').text(firstLic.plan_name).html() + ')</span>';
561 }
562 html += '</div>';
563
564 // License details table
565 html += '<table class="gvlicense-modal-table" style="margin-bottom:10px;">';
566
567 // Show each license in the group
568 for (var li = 0; li < group.licenses.length; li++) {
569 var l = group.licenses[li];
570 if (isBundle) {
571 html += '<tr><th colspan="2" style="padding-top:8px;color:#23282d;font-weight:600;">' +
572 $('<span>').text(l.plugin_slug).html() + '</th></tr>';
573 }
574 html += '<tr><th>License Key</th><td><code>' + $('<span>').text(l.license_key).html() + '</code></td></tr>';
575 if (l.transaction_id) {
576 html += '<tr><th>Transaction</th><td><code>' + $('<span>').text(l.transaction_id).html() + '</code></td></tr>';
577 }
578 html += '<tr><th>Sites</th><td>' + l.sites_used + ' / ' + l.max_sites + ' used</td></tr>';
579 if (l.expires_at) {
580 html += '<tr><th>Expires</th><td>' + $('<span>').text(l.expires_at).html() + '</td></tr>';
581 }
582 }
583
584 if (subIdDisplay) {
585 html += '<tr><th>Subscription</th><td><code style="font-size:11px;">' + $('<span>').text(subIdDisplay).html() + '</code></td></tr>';
586 }
587 html += '</table>';
588
589 // Action buttons
590 html += '<div class="gvlicense-overlap-actions" style="display:flex;gap:8px;" data-subscription-id="' + subIdDisplay + '">';
591 html += '<button type="button" class="button gvlicense-overlap-cancel-btn" data-subscription-id="' + subIdDisplay + '">' +
592 'Cancel old subscription</button>';
593 html += '<button type="button" class="button gvlicense-overlap-keep-btn" data-subscription-id="' + subIdDisplay + '">' +
594 'Keep both active</button>';
595 html += '</div>';
596
597 html += '</div>';
598 }
599
600 // Help text
601 html += '<div style="background:#fff8e1;border:1px solid #ffe082;border-radius:4px;padding:12px 14px;margin-top:8px;margin-bottom:12px;">' +
602 '<p style="margin:0;font-size:12px;color:#6d4c00;line-height:1.5;">' +
603 '<strong>Note:</strong> If you choose to keep both subscriptions active, you can use the old license key to activate on another domain ' +
604 '(if your max sites limit allows). Save your license key and transaction ID for future reference.' +
605 '<br>Cancelled subscriptions remain active until the end of the current billing period.' +
606 '</p></div>';
607
608 // Validation message (hidden by default)
609 html += '<p class="gvlicense-overlap-validation" style="display:none;color:#c62828;font-size:12px;margin:0 0 10px;text-align:right;">' +
610 'Please choose an action for each subscription above before proceeding.' +
611 '</p>';
612
613 // Footer buttons
614 html += '<div style="display:flex;gap:10px;justify-content:flex-end;padding-top:12px;border-top:1px solid #eee;">' +
615 '<button type="button" class="button gvlicense-overlap-abort-btn">Cancel Purchase</button>' +
616 '<button type="button" class="button button-primary gvlicense-overlap-proceed-btn" disabled>Continue to Checkout</button>' +
617 '</div></div>';
618
619 $('#gvlicense-modal-body').html(html);
620
621 // Helper: check if all subscriptions have a decision and enable/disable proceed button
622 function updateProceedState() {
623 var allDecided = true;
624 for (var sk in seenSubs) {
625 var subSid = seenSubs[sk].subscription_id;
626 if (subSid && !subscriptionChoices[subSid]) {
627 allDecided = false;
628 break;
629 }
630 }
631 var $btn = $('.gvlicense-overlap-proceed-btn');
632 var $msg = $('.gvlicense-overlap-validation');
633 if (allDecided) {
634 $btn.prop('disabled', false);
635 $msg.hide();
636 } else {
637 $btn.prop('disabled', true);
638 }
639 }
640
641 // Helper: collect license/product info for a subscription from overlaps data
642 function getSubInfo(sid) {
643 var info = { license_keys: [], product_id: '', product_name: '', price_id: '' };
644 for (var oi = 0; oi < overlaps.length; oi++) {
645 if ((overlaps[oi].subscription_id || '') === sid) {
646 if (overlaps[oi].license_key) info.license_keys.push(overlaps[oi].license_key);
647 if (!info.product_id && overlaps[oi].product_id) info.product_id = overlaps[oi].product_id;
648 if (!info.product_name && overlaps[oi].product_name) info.product_name = overlaps[oi].product_name;
649 if (!info.price_id && overlaps[oi].price_id) info.price_id = overlaps[oi].price_id;
650 }
651 }
652 return info;
653 }
654
655 // Per-subscription toggle handlers
656 $('#gvlicense-manage-modal').off('click.overlap').on('click.overlap', '.gvlicense-overlap-cancel-btn', function () {
657 var sid = $(this).data('subscription-id');
658 var $actions = $(this).closest('.gvlicense-overlap-actions');
659 $actions.html(
660 '<span style="color:#c62828;font-weight:600;">Will be cancelled after purchase</span>' +
661 ' <button type="button" class="button button-link gvlicense-overlap-undo-btn" data-subscription-id="' + sid + '" style="margin-left:8px;">Undo</button>'
662 );
663 var info = getSubInfo(sid);
664 subscriptionChoices[sid] = {
665 action: 'cancel',
666 product_id: info.product_id,
667 product_name: info.product_name,
668 license_keys: info.license_keys,
669 price_id: info.price_id
670 };
671 updateProceedState();
672 }).on('click.overlap', '.gvlicense-overlap-keep-btn', function () {
673 var sid = $(this).data('subscription-id');
674 var $actions = $(this).closest('.gvlicense-overlap-actions');
675 var info = getSubInfo(sid);
676
677 // Collect license keys where current domain is in activated_sites (for deactivation)
678 var deactivateKeys = [];
679 for (var oi = 0; oi < overlaps.length; oi++) {
680 if ((overlaps[oi].subscription_id || '') === sid && overlaps[oi].license_key) {
681 var sites = overlaps[oi].activated_sites || [];
682 var domainNorm = siteDomain.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase();
683 for (var si = 0; si < sites.length; si++) {
684 var siteNorm = sites[si].replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase();
685 if (siteNorm === domainNorm) {
686 deactivateKeys.push(overlaps[oi].license_key);
687 break;
688 }
689 }
690 }
691 }
692
693 $actions.html(
694 '<span style="color:#1e7e34;font-weight:600;">Keeping active</span>' +
695 ' <button type="button" class="button button-link gvlicense-overlap-undo-btn" data-subscription-id="' + sid + '" style="margin-left:8px;">Undo</button>'
696 );
697 subscriptionChoices[sid] = {
698 action: 'keep',
699 product_id: info.product_id,
700 product_name: info.product_name,
701 license_keys: info.license_keys,
702 price_id: info.price_id,
703 deactivate_keys: deactivateKeys
704 };
705 updateProceedState();
706 }).on('click.overlap', '.gvlicense-overlap-undo-btn', function () {
707 var sid = $(this).data('subscription-id');
708 var $actions = $(this).closest('.gvlicense-overlap-actions');
709 $actions.html(
710 '<button type="button" class="button gvlicense-overlap-cancel-btn" data-subscription-id="' + sid + '">Cancel old subscription</button>' +
711 '<button type="button" class="button gvlicense-overlap-keep-btn" data-subscription-id="' + sid + '">Keep both active</button>'
712 );
713 delete subscriptionChoices[sid];
714 updateProceedState();
715 });
716
717 // Proceed — require a choice for every overlapping subscription
718 $('#gvlicense-manage-modal').on('click.overlap', '.gvlicense-overlap-proceed-btn', function () {
719 // Safety check (button should already be disabled, but guard anyway)
720 var allDecided = true;
721 for (var sk in seenSubs) {
722 var subSid = seenSubs[sk].subscription_id;
723 if (subSid && !subscriptionChoices[subSid]) {
724 allDecided = false;
725 break;
726 }
727 }
728 if (!allDecided) {
729 $('.gvlicense-overlap-validation').show();
730 return;
731 }
732
733 $('#gvlicense-manage-modal').off('click.overlap').hide();
734 var hasChoices = Object.keys(subscriptionChoices).length > 0;
735 var checkoutOpts = hasChoices ? {
736 site_domain: siteDomain,
737 decided_at: new Date().toISOString(),
738 overlap_type: overlapType,
739 subscriptions: subscriptionChoices
740 } : null;
741 if (typeof onProceed === 'function') onProceed(checkoutOpts);
742 });
743
744 // Abort
745 $('#gvlicense-manage-modal').on('click.overlap', '.gvlicense-overlap-abort-btn', function () {
746 $('#gvlicense-manage-modal').off('click.overlap').hide();
747 });
748
749 // Close button
750 $('#gvlicense-manage-modal').on('click.overlap', '.gvlicense-modal-close', function () {
751 $('#gvlicense-manage-modal').off('click.overlap').hide();
752 });
753 },
754
755 /**
756 * Open a popup, create a transaction, and redirect.
757 * Called from a user click handler inside the modal, so the popup won't be blocked.
758 */
759 startCheckout: function (priceId, productId, $btn) {
760 const self = this;
761
762 // Open the popup from this user-initiated click context
763 // 1. Define desired popup dimensions
764 const width = 850;
765 const height = 700;
766
767 // 2. Calculate the center position relative to the user's primary monitor screen
768 const left = (window.screen.width / 2) - (width / 2);
769 const top = (window.screen.height / 2) - (height / 2);
770 var checkoutWindow = window.open(this.config.checkout_loading_url, 'gVectorsCheckout', `width=${width},height=${height},top=${top},left=${left},scrollbars=yes,resizable=yes`);
771 if (!checkoutWindow) {
772 self.showToast('Popup blocked. Please allow popups and try again.', 'error');
773 return;
774 }
775
776 self.setLoading($btn, true);
777 self.showCheckoutOverlay('Creating checkout...');
778
779 var ajaxData = {
780 price_id: priceId,
781 product_id: productId,
782 };
783
784 // Pass full customer overlap choices to be stored on the transaction
785 if (self._checkoutOptions) {
786 ajaxData.checkout_options = self._checkoutOptions;
787 }
788
789 this.ajax('create_checkout', ajaxData, function (response) {
790 self.setLoading($btn, false);
791
792 if (!response.success || !response.data || !response.data.transaction_id) {
793 var msg = (response.data && response.data.message)
794 ? response.data.message
795 : self.config.i18n.checkoutError;
796
797 self.removeCheckoutOverlay();
798 self.showToast(msg, 'error');
799 if (checkoutWindow && !checkoutWindow.closed) checkoutWindow.close();
800 return;
801 }
802
803 var transactionId = response.data.transaction_id;
804 self._currentTransactionId = transactionId;
805
806 self.savePendingTransaction(transactionId);
807 self.updateCheckoutOverlay('Redirecting to secure checkout...');
808 self.proceedToCheckout(transactionId, checkoutWindow);
809 });
810 },
811
812 // ==========================================
813 // License
814 // ==========================================
815
816 /**
817 * After a successful purchase, activate the license on the proxy server
818 * (registers this site domain on the license) then refresh UI.
819 */
820 activateLicenseAfterPurchase: function (licenseKey, productId) {
821 const self = this;
822
823 this.ajax('activate_license', {
824 license_key: licenseKey,
825 product_id: productId || '',
826 }, function (response) {
827 if (response.success) {
828 self.showToast(self.config.i18n.purchaseComplete || 'Purchase completed! License activated.', 'success');
829 } else {
830 const msg = (response.data && response.data.message) ? response.data.message : 'License saved but activation failed.';
831 self.showToast(msg, 'error');
832 }
833 self.loadProducts();
834 self.loadLicenses();
835 });
836 },
837
838 /**
839 * Activate all licenses from a bundle purchase sequentially.
840 */
841 activateBundleLicenses: function (licenses) {
842 const self = this;
843 const remaining = licenses.slice();
844 let activated = 0;
845 const total = licenses.length;
846
847 self.showToast('Bundle purchased! Activating ' + total + ' licenses...', 'info');
848
849 function activateNext () {
850 if (!remaining.length) {
851 self.showToast('Bundle complete! ' + activated + '/' + total + ' licenses activated.', 'success');
852 self.loadProducts();
853 self.loadLicenses();
854 return;
855 }
856 const lic = remaining.shift();
857 self.ajax('activate_license', {
858 license_key: lic.license_key,
859 product_id: lic.product_id || '',
860 }, function (response) {
861 if (response.success) {
862 activated++;
863 }
864 activateNext();
865 });
866 }
867
868 activateNext();
869 },
870
871 unifiedActivate: function () {
872 const self = this;
873 const key = $('#gvlicense-unified-key').val().trim();
874 const productId = $('#gvlicense-unified-product').val();
875 const $status = $('#gvlicense-unified-status');
876 const $btn = $('#gvlicense-unified-activate-btn');
877
878 this.setLoading($btn, true);
879 $status.text(this.config.i18n.processing).removeClass('success error');
880
881 // Empty key = activate-by-domain; proxy auto-detects all other key types
882 this.ajax('unified_activate', {
883 key: key,
884 product_id: productId || '',
885 }, function (response) {
886 self.setLoading($btn, false);
887 if (response.success) {
888 self.showToast(response.data.message, 'success');
889 $status.text(response.data.message).addClass('success').removeClass('error');
890 $('#gvlicense-unified-key').val('');
891 self.loadProducts();
892 self.loadLicenses();
893 } else {
894 self.showToast(response.data.message, 'error');
895 $status.text(response.data.message).addClass('error').removeClass('success');
896 }
897 });
898 },
899
900 deactivateLicense: function (productId, $btn) {
901 const self = this;
902 this.setLoading($btn, true);
903
904 this.ajax('deactivate_license', {
905 product_id: productId,
906 }, function (response) {
907 self.setLoading($btn, false);
908 if (response.success) {
909 self.showToast(response.data.message, 'success');
910 self.loadProducts();
911 self.loadLicenses();
912 $('#gvlicense-manage-modal').hide();
913 } else {
914 self.showToast(response.data.message, 'error');
915 }
916 });
917 },
918
919 validateAllLicenses: function ($btn) {
920 const self = this;
921 this.setLoading($btn, true);
922
923 this.ajax('validate_license', {}, function (response) {
924 self.setLoading($btn, false);
925
926 if (response.success) {
927 const data = response.data;
928
929 if (data.reason === 'server_unavailable') {
930 self.showToast(data.message, 'warning');
931 return;
932 }
933
934 const toastType = data.valid ? 'success' : 'warning';
935 self.showToast(data.message, toastType);
936
937 // Update the timestamp label next to the button
938 const now = new Date();
939 const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
940 $('#gvlicense-validated-stamp').html('&#10003; Validated at ' + timeStr).show();
941
942 // Refresh the license table and product grid with fresh data
943 if (data.all_licenses) {
944 self.renderLicenses(data.all_licenses);
945 }
946 self.loadProducts();
947 } else {
948 self.showToast(response.data.message || 'Validation failed', 'error');
949 }
950 });
951 },
952
953 loadLicenses: function () {
954 const self = this;
955 this.ajax('get_licenses', {}, function (response) {
956 if (response.success && response.data) {
957 self.renderLicenses(response.data);
958 }
959 });
960 },
961
962 renderLicenses: function (licenses) {
963 setTimeout(() => {
964 const $section = $('#gvlicense-licenses-section');
965 const $tbody = $('#gvlicense-licenses-table tbody');
966
967 if (!$section.length) return;
968
969 const keys = Object.keys(licenses);
970 if (!keys.length) {
971 $section.hide();
972 return;
973 }
974
975 $section.show();
976 $tbody.empty();
977
978 for (let i = 0; i < keys.length; i++) {
979 const pid = keys[i];
980 const lic = licenses[pid];
981 const maskedKey = lic.license_key
982 ? lic.license_key.substring(0, 8) + '••••••••' + lic.license_key.slice(-4)
983 : '—';
984 let statusClass = 'gvlicense-status-unknown';
985 if (lic.status === 'active') statusClass = 'gvlicense-status-active';
986 else if (lic.status === 'trial') statusClass = 'gvlicense-status-trial';
987 else if (lic.status === 'expired' || lic.status === 'cancelled') statusClass = 'gvlicense-status-expired';
988
989 let actions = '';
990
991 // Install & Activate / Activate button (same as product grid)
992 const product = this.findProduct(pid);
993 if (product && (lic.status === 'active' || lic.status === 'trial')) {
994 if (product.addon_status === 'not_installed') {
995 actions += '<button type="button" class="button button-small button-primary gvlicense-install-btn" data-product-id="' + pid + '">' + (this.config.i18n.install || 'Install & Activate') + '</button> ';
996 } else if (product.addon_status === 'installed') {
997 actions += '<button type="button" class="button button-small button-primary gvlicense-activate-addon-btn" data-product-id="' + pid + '" data-plugin-slug="' + (product.plugin_slug || '') + '">' + (this.config.i18n.activate || 'Activate') + '</button> ';
998 }
999 }
1000
1001 // Manage button (same as product grid — opens full info popup with timeline)
1002 if (product) {
1003 actions += '<button type="button" class="button button-small gvlicense-manage-btn" data-product-id="' + pid + '">Manage</button> ';
1004 }
1005
1006 actions += '<button type="button" class="button button-small gvlicense-deactivate-btn" data-product-id="' + pid + '">Deactivate</button>';
1007 if (lic.subscription_id) {
1008 actions += ' <button type="button" class="button button-small gvlicense-manage-paddle-btn" data-subscription-id="' + lic.subscription_id + '"><span class="dashicons dashicons-external" style="vertical-align:middle;font-size:14px;width:14px;height:14px;margin-right:2px;"></span>Manage on Paddle</button>';
1009 }
1010
1011 $tbody.append(
1012 '<tr>' +
1013 '<td><strong>' + (lic.product_name || pid) + '</strong></td>' +
1014 '<td><code>' + maskedKey + '</code></td>' +
1015 '<td><span class="gvlicense-status-badge ' + statusClass + '">' + (lic.status || 'unknown') + '</span></td>' +
1016 '<td>' + (lic.expires_at || 'Lifetime') + '</td>' +
1017 '<td>' + actions + '</td>' +
1018 '</tr>'
1019 );
1020 }
1021 }, 500);
1022 },
1023
1024 // ==========================================
1025 // Trial
1026 // ==========================================
1027 startTrial: function (productId, $btn) {
1028 const self = this;
1029 this.setLoading($btn, true);
1030
1031 this.ajax('start_trial', {
1032 product_id: productId,
1033 }, function (response) {
1034 self.setLoading($btn, false);
1035 if (response.success) {
1036 self.showToast(response.data.message, 'success');
1037 self.loadProducts();
1038 self.loadLicenses();
1039 } else {
1040 self.showToast(response.data.message, 'error');
1041 }
1042 });
1043 },
1044
1045 // ==========================================
1046 // Addons
1047 // ==========================================
1048 installAndActivateAddon: function (productId, $btn) {
1049 const self = this;
1050 this.setLoading($btn, true);
1051 $btn.text(this.config.i18n.processing);
1052
1053 this.ajax('install_activate_addon', {
1054 product_id: productId,
1055 }, function (response) {
1056 self.setLoading($btn, false);
1057 if (response.success) {
1058 self.showToast(response.data.message, 'success');
1059 // Reload products first (updates addon_status), then licenses,
1060 // so renderLicenses sees the updated addon_status via findProduct()
1061 self.ajax('get_products', {}, function (prodResponse) {
1062 if (prodResponse.success && prodResponse.data) {
1063 const data = prodResponse.data;
1064 self.products = data.products || data;
1065 self.checkoutMode = data.checkout_mode || 'both';
1066 self.renderProducts(self.products);
1067 }
1068 self.loadLicenses();
1069 });
1070 } else {
1071 self.showToast(response.data.message, 'error');
1072 $btn.text(self.config.i18n.install);
1073 }
1074 });
1075 },
1076
1077 // ==========================================
1078 // Manage Modal
1079 // ==========================================
1080 openManageModal: function (productId) {
1081 const self = this;
1082 const product = self.findProduct(productId);
1083 if (!product) return;
1084
1085 const $modal = $('#gvlicense-manage-modal');
1086 const $title = $('#gvlicense-modal-title');
1087 const $body = $('#gvlicense-modal-body');
1088
1089 $title.text(product.name);
1090
1091 // Status badge class
1092 let statusClass = 'gvlicense-status-unknown';
1093 if (product.is_licensed) statusClass = 'gvlicense-status-active';
1094 else if (product.is_trial) statusClass = 'gvlicense-status-trial';
1095 else if (product.license_status === 'Cancelled' || product.license_status === 'cancelled' || product.license_status.toLowerCase() === 'expired') statusClass = 'gvlicense-status-expired';
1096
1097 // Addon status label
1098 const addonLabels = {
1099 'active': 'Installed & Active',
1100 'installed': 'Installed (Inactive)',
1101 'not_installed': 'Not Installed',
1102 };
1103 const addonLabel = addonLabels[product.addon_status] || product.addon_status;
1104 const addonClass = product.addon_status === 'active' ? 'gvlicense-modal-addon-active' :
1105 product.addon_status === 'installed' ? 'gvlicense-modal-addon-installed' : 'gvlicense-modal-addon-none';
1106
1107 // Build modal content
1108 let html = '<div class="gvlicense-manage-info">';
1109
1110 // License section
1111 html += '<div class="gvlicense-modal-section">';
1112 html += '<h4>License Information</h4>';
1113 html += '<table class="gvlicense-modal-table">';
1114 html += '<tr><th>Status</th><td><span class="gvlicense-status-badge ' + statusClass + '">' + product.license_status + '</span></td></tr>';
1115
1116 if (product.license_key) {
1117 const masked = product.license_key.substring(0, 8) + '••••••••' + product.license_key.slice(-4);
1118 html += '<tr><th>License Key</th><td><code>' + masked + '</code></td></tr>';
1119 }
1120 if (product.customer_id) {
1121 html += '<tr><th>Customer</th><td>' + product.customer_id + '</td></tr>';
1122 }
1123 if (product.plan_name) {
1124 html += '<tr><th>Plan</th><td>' + product.plan_name + '</td></tr>';
1125 }
1126 if (product.active_price_formatted) {
1127 html += '<tr><th>Price</th><td>' + product.active_price_formatted + ' <span class="gvlicense-modal-interval">' + (product.active_price_interval || '') + '</span></td></tr>';
1128 }
1129 html += '</table></div>';
1130
1131 // Subscription / Billing section
1132 html += '<div class="gvlicense-modal-section">';
1133 html += '<h4>Billing &amp; Subscription</h4>';
1134 html += '<table class="gvlicense-modal-table">';
1135 if (product.subscription_id) {
1136 html += '<tr><th>Subscription ID</th><td><code>' + product.subscription_id + '</code></td></tr>';
1137 }
1138 if (product.expires_at) {
1139 const expiresDate = new Date(product.expires_at);
1140 const now = new Date();
1141 const daysLeft = Math.ceil((expiresDate - now) / (1000 * 60 * 60 * 24));
1142 const expiresFormatted = expiresDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
1143 const localStatus = (product.license_status || '').toLowerCase();
1144 const daysLabel = daysLeft > 0 ? ' (' + daysLeft + ' days remaining)' : daysLeft === 0 ? ' <span class="gvlicense-modal-expired">(Today Should expire)</span>' : ' <span class="gvlicense-modal-expired">(expired)</span>';
1145 if (localStatus === 'cancelled') {
1146 html += '<tr><th>License Expires</th><td>' + expiresFormatted + daysLabel + '<br><small style="color:#dc3232;">Subscription cancelled — license will not auto-renew</small></td></tr>';
1147 } else {
1148 // Show license expiration
1149 html += '<tr><th>License Expires</th><td>' + expiresFormatted + daysLabel + '</td></tr>';
1150 // Show next charge info based on billing interval
1151 const interval = (product.active_price_interval || '').toLowerCase().replace(/^[\s\/]+/, '').trim();
1152 if (interval && product.subscription_id) {
1153 let periodDays = 365;
1154 if (interval.indexOf('month') !== -1) periodDays = 30;
1155 else if (interval.indexOf('week') !== -1) periodDays = 7;
1156 const nextCharge = new Date(now.getTime() + periodDays * 24 * 60 * 60 * 1000);
1157 // If license has been extended beyond one billing cycle, note that
1158 if (daysLeft > periodDays + 30) {
1159 html += '<tr><th>Next Charge</th><td>~' + nextCharge.toLocaleDateString(undefined, {
1160 year: 'numeric',
1161 month: 'long',
1162 }) + ' <span class="gvlicense-modal-interval">(' + product.active_price_interval + ')</span>'
1163 + '<br><small style="color:#388e3c;">License has extra time from previous purchases</small></td></tr>';
1164 } else {
1165 html += '<tr><th>Next Charge</th><td>~' + nextCharge.toLocaleDateString(undefined, {
1166 year: 'numeric',
1167 month: 'long',
1168 }) + ' <span class="gvlicense-modal-interval">(' + product.active_price_interval + ')</span></td></tr>';
1169 }
1170 }
1171 }
1172 } else {
1173 html += '<tr><th>License</th><td>Lifetime (no expiration)</td></tr>';
1174 }
1175 if (product.activated_at) {
1176 const activatedDate = new Date(product.activated_at);
1177 html += '<tr><th>Activated On</th><td>' + activatedDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) + '</td></tr>';
1178 }
1179 html += '</table></div>';
1180
1181 // Site Activation section
1182 html += '<div class="gvlicense-modal-section">';
1183 html += '<h4>Site Activation</h4>';
1184 html += '<table class="gvlicense-modal-table">';
1185 let sites = product.activated_sites || [];
1186 if (typeof sites === 'string') {
1187 try { sites = JSON.parse(sites); } catch (e) { sites = []; }
1188 }
1189 const maxSites = product.max_sites || 1;
1190 html += '<tr><th>Sites Used</th><td>' + (Array.isArray(sites) ? sites.length : 0) + ' / ' + maxSites + '</td></tr>';
1191 if (Array.isArray(sites) && sites.length) {
1192 let sitesList = '';
1193 for (let s = 0; s < sites.length; s++) {
1194 sitesList += '<span class="gvlicense-modal-site">' + sites[s] + '</span>';
1195 }
1196 html += '<tr><th>Activated Sites</th><td>' + sitesList + '</td></tr>';
1197 }
1198 html += '</table></div>';
1199
1200 // Addon section
1201 html += '<div class="gvlicense-modal-section">';
1202 html += '<h4>Addon</h4>';
1203 html += '<table class="gvlicense-modal-table">';
1204 html += '<tr><th>Plugin</th><td>' + (product.plugin_slug || '—') + '</td></tr>';
1205 html += '<tr><th>Addon Status</th><td><span class="' + addonClass + '">' + addonLabel + '</span></td></tr>';
1206 html += '</table></div>';
1207
1208 // Subscription Timeline section (loaded async)
1209 html += '<div class="gvlicense-modal-section">';
1210 html += '<h4>Timeline</h4>';
1211 html += '<div id="gvlicense-timeline-container">';
1212 html += '<div class="gvlicense-loading"><span class="spinner is-active" style="float:none;"></span> Loading timeline...</div>';
1213 html += '</div></div>';
1214
1215 // Last validated
1216 if (product.last_validated) {
1217 const validatedDate = new Date(product.last_validated * 1000);
1218 html += '<div class="gvlicense-modal-footer-info">Last validated: ' + validatedDate.toLocaleString() + '</div>';
1219 }
1220
1221 html += '</div>';
1222
1223 // Actions
1224 html += '<div class="gvlicense-manage-actions">';
1225 if (product.addon_status === 'not_installed' && (product.is_licensed || product.is_trial)) {
1226 html += '<button type="button" class="button button-primary gvlicense-install-btn" data-product-id="' + productId + '">' + this.config.i18n.install + '</button>';
1227 }
1228 html += '<button type="button" class="button gvlicense-deactivate-btn" data-product-id="' + productId + '">' + this.config.i18n.deactivateLicense + '</button>';
1229
1230 if (product.subscription_id) {
1231 const licStatus = (product.license_status || '').toLowerCase();
1232 if (licStatus !== 'cancelled' && licStatus !== 'expired') {
1233 html += '<button type="button" class="button gvlicense-cancel-sub-btn" data-subscription-id="' + product.subscription_id + '" data-product-id="' + productId + '">Cancel Subscription</button>';
1234 }
1235 html += '<button type="button" class="button gvlicense-manage-paddle-btn" data-subscription-id="' + product.subscription_id + '"><span class="dashicons dashicons-external" style="margin-right:3px;"></span>Manage on Paddle</button>';
1236 }
1237 html += '</div>';
1238
1239 $body.html(html);
1240 $modal.show();
1241
1242 // Load timeline asynchronously
1243 if (product.license_key) {
1244 this.loadTimeline(product.license_key);
1245 } else {
1246 $('#gvlicense-timeline-container').html('<p class="gvlicense-timeline-empty">No timeline data available.</p>');
1247 }
1248 },
1249
1250 // ==========================================
1251 // Subscription Timeline
1252 // ==========================================
1253 loadTimeline: function (licenseKey) {
1254 this.ajax('get_timeline', {
1255 license_key: licenseKey,
1256 }, function (response) {
1257 const $container = $('#gvlicense-timeline-container');
1258 if (!$container.length) return;
1259
1260 const timelineData = response.data || {};
1261 const events = timelineData.events || timelineData;
1262 const currentLicenseKey = timelineData.current_license_key || '';
1263 if (response.success && events && events.length) {
1264
1265 // Check if subscription is canceled from timeline data and update UI accordingly
1266 // Only consider events from the CURRENT license key (not old/deleted ones)
1267 let hasCancelled = false;
1268 let hasCancelScheduled = false;
1269 for (let ci = 0; ci < events.length; ci++) {
1270 const evtKey = events[ci].license_key || '';
1271 // Skip events from old license keys — their cancel status doesn't apply to current
1272 if (currentLicenseKey && evtKey && evtKey !== currentLicenseKey) continue;
1273 if (events[ci].action_type === 'cancelled') hasCancelled = true;
1274 if (events[ci].action_type === 'cancel_scheduled') hasCancelScheduled = true;
1275 }
1276
1277 const $actions = $('#gvlicense-manage-modal .gvlicense-manage-actions');
1278 const pid = $actions.find('[data-product-id]').first().data('product-id') || '';
1279
1280 if (hasCancelled) {
1281 // Truly canceled (from Paddle directly) — update billing label
1282 $('#gvlicense-manage-modal .gvlicense-modal-table th').each(function () {
1283 if ($(this).text() === 'Next Renewal') {
1284 $(this).text('Active Until');
1285 const $td = $(this).next('td');
1286 $td.append('<br><small style="color:#dc3232;">Subscription cancelled — license will not auto-renew</small>');
1287 }
1288 });
1289 // Remove cancel/resume buttons, show Resubscribe
1290 $actions.find('.gvlicense-cancel-sub-btn, .gvlicense-resume-sub-btn').remove();
1291 if ($actions.length && !$actions.find('.gvlicense-resubscribe-btn').length && pid) {
1292 $actions.find('.gvlicense-deactivate-btn').after(' <button type="button" class="button button-primary gvlicense-resubscribe-btn" data-product-id="' + pid + '">Resubscribe</button>');
1293 }
1294 } else if (hasCancelScheduled) {
1295 // Scheduled cancellation — update billing label and swap Cancel to Resume
1296 $('#gvlicense-manage-modal .gvlicense-modal-table th').each(function () {
1297 if ($(this).text() === 'Next Renewal') {
1298 $(this).text('Active Until');
1299 const $td = $(this).next('td');
1300 $td.append('<br><small style="color:#d68000;">Cancellation scheduled — license will not auto-renew after this period</small>');
1301 }
1302 });
1303 // Swap Cancel button with Resume
1304 const $cancelBtn = $actions.find('.gvlicense-cancel-sub-btn');
1305 if ($cancelBtn.length) {
1306 const subId = $cancelBtn.data('subscription-id');
1307 $cancelBtn.replaceWith('<button type="button" class="button button-primary gvlicense-resume-sub-btn" data-subscription-id="' + subId + '" data-product-id="' + pid + '">Resume Subscription</button>');
1308 }
1309 }
1310 let html = '<div class="gvlicense-timeline">';
1311
1312 const actionLabels = {
1313 'created': 'Subscription Created',
1314 'renewed': 'Subscription Renewed',
1315 'extended': 'License Extended',
1316 'cancelled': 'Subscription Cancelled',
1317 'cancel_scheduled': 'Cancellation Scheduled',
1318 'replaced': 'Subscription Replaced',
1319 'resumed': 'Subscription Resumed',
1320 'upgraded': 'Plan Upgraded',
1321 'downgraded': 'Plan Downgraded',
1322 'payment': 'Payment Processed',
1323 };
1324
1325 const actionIcons = {
1326 'created': 'dashicons-plus-alt',
1327 'renewed': 'dashicons-update',
1328 'extended': 'dashicons-calendar',
1329 'cancelled': 'dashicons-dismiss',
1330 'cancel_scheduled': 'dashicons-clock',
1331 'replaced': 'dashicons-randomize',
1332 'resumed': 'dashicons-controls-play',
1333 'upgraded': 'dashicons-arrow-up-alt',
1334 'downgraded': 'dashicons-arrow-down-alt',
1335 'payment': 'dashicons-money-alt',
1336 };
1337
1338 const actionColors = {
1339 'created': 'gvlicense-tl-green',
1340 'renewed': 'gvlicense-tl-blue',
1341 'extended': 'gvlicense-tl-blue',
1342 'cancelled': 'gvlicense-tl-red',
1343 'cancel_scheduled': 'gvlicense-tl-orange',
1344 'replaced': 'gvlicense-tl-orange',
1345 'resumed': 'gvlicense-tl-green',
1346 'upgraded': 'gvlicense-tl-green',
1347 'downgraded': 'gvlicense-tl-orange',
1348 'payment': 'gvlicense-tl-blue',
1349 };
1350
1351 let lastLicenseKey = '';
1352 for (let i = 0; i < events.length; i++) {
1353 const evt = events[i];
1354 const evtLicenseKey = evt.license_key || '';
1355 const actionType = evt.action_type || 'created';
1356
1357 // Draw a separator line when the license key changes
1358 if (currentLicenseKey && evtLicenseKey && evtLicenseKey !== lastLicenseKey && lastLicenseKey !== '') {
1359 const isCurrent = (evtLicenseKey === currentLicenseKey);
1360 html += '<div class="gvlicense-tl-separator">';
1361 html += '<span class="gvlicense-tl-separator-label">' + (isCurrent ? 'Current License' : 'Previous License') + '</span>';
1362 html += '</div>';
1363 }
1364 if (evtLicenseKey) lastLicenseKey = evtLicenseKey;
1365
1366 const label = actionLabels[actionType] || actionType;
1367 const icon = actionIcons[actionType] || 'dashicons-marker';
1368 const colorClass = actionColors[actionType] || 'gvlicense-tl-gray';
1369 const eventDate = evt.date || '';
1370 let dateFormatted = '';
1371 if (eventDate) {
1372 const d = new Date(eventDate.replace(' ', 'T') + 'Z');
1373 dateFormatted = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
1374 }
1375
1376 // Dim old license events
1377 const isOldLicense = currentLicenseKey && evtLicenseKey && evtLicenseKey !== currentLicenseKey;
1378 const itemClass = 'gvlicense-tl-item ' + colorClass + (isOldLicense ? ' gvlicense-tl-old' : '');
1379
1380 html += '<div class="' + itemClass + '">';
1381 html += '<div class="gvlicense-tl-dot"><span class="dashicons ' + icon + '"></span></div>';
1382 html += '<div class="gvlicense-tl-content">';
1383 html += '<div class="gvlicense-tl-label">' + label + '</div>';
1384 if (dateFormatted) {
1385 html += '<div class="gvlicense-tl-date">' + dateFormatted + '</div>';
1386 }
1387 if (evt.expires_at) {
1388 const expD = new Date(evt.expires_at.replace(' ', 'T') + 'Z');
1389 html += '<div class="gvlicense-tl-detail">Valid until: ' + expD.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + '</div>';
1390 }
1391 if (evt.site_domain) {
1392 html += '<div class="gvlicense-tl-detail">From: <code>' + evt.site_domain + '</code></div>';
1393 }
1394 if (evt.note) {
1395 html += '<div class="gvlicense-tl-detail"><em>' + evt.note + '</em></div>';
1396 }
1397 if (evt.source === 'paddle' && !evt.site_domain && (actionType === 'cancelled' || actionType === 'paused')) {
1398 html += '<div class="gvlicense-tl-detail"><em>Action performed outside your site (Paddle portal/email)</em></div>';
1399 }
1400 if (evt.old_subscription_id) {
1401 html += '<div class="gvlicense-tl-detail">Replaced: <code>' + evt.old_subscription_id + '</code></div>';
1402 }
1403 if (evt.transaction_id) {
1404 html += '<div class="gvlicense-tl-detail">Transaction: <code>' + evt.transaction_id + '</code></div>';
1405 }
1406 html += '</div></div>';
1407 }
1408
1409 html += '</div>';
1410 $container.html(html);
1411 } else {
1412 $container.html('<p class="gvlicense-timeline-empty">No timeline of events was found.</p>');
1413 }
1414 });
1415 },
1416
1417 // ==========================================
1418 // Subscription Management
1419 // ==========================================
1420 manageSubscription: function (action, subscriptionId, $btn) {
1421 const self = this;
1422 const productId = $btn.data('product-id') || '';
1423 const ajaxAction = action + '_subscription';
1424 this.setLoading($btn, true);
1425
1426 this.ajax(ajaxAction, {
1427 subscription_id: subscriptionId,
1428 }, function (response) {
1429 self.setLoading($btn, false);
1430 if (response.success) {
1431 self.showToast(response.data.message, 'success');
1432 // Reload products and re-open the modal to show updated state
1433 self.ajax('get_products', {}, function (prodResponse) {
1434 if (prodResponse.success && prodResponse.data) {
1435 const data = prodResponse.data;
1436 const products = data.products || data;
1437 self.products = products;
1438 self.checkoutMode = data.checkout_mode || 'both';
1439 self.renderProducts(products);
1440 self.loadLicenses();
1441 // Re-open modal with refreshed data
1442 if (productId) {
1443 self.openManageModal(productId);
1444 }
1445 }
1446 });
1447 } else {
1448 self.showToast(response.data.message, 'error');
1449 }
1450 });
1451 },
1452
1453 // ==========================================
1454 // Helpers
1455 // ==========================================
1456 ajax: function (action, data, callback) {
1457 data.action = this.ajaxPrefix + action;
1458 data.nonce = this.config.nonce;
1459
1460 $.post(this.config.ajaxUrl, data, function (response) {
1461 if (typeof callback === 'function') callback(response);
1462 }).fail(function () {
1463 if (typeof callback === 'function') {
1464 callback({ success: false, data: { message: 'Request failed' } });
1465 }
1466 });
1467 },
1468
1469 findProduct: function (productId) {
1470 for (let i = 0; i < this.products.length; i++) {
1471 if (this.products[i].id === productId) return this.products[i];
1472 }
1473 return null;
1474 },
1475
1476 /**
1477 * Redirect the pre-opened checkout window to Paddle.
1478 */
1479 proceedToCheckout: function (transactionId, checkoutWindow) {
1480 const self = this;
1481 checkoutWindow.location.href = self.config.checkout_url + '?_ptxn=' + encodeURIComponent(transactionId);
1482 const messageHandler = function(event) {
1483 if (event.data && event.data.type === 'paddle_checkout_complete') {
1484 if( self.config.proxy_server_url === event.origin ){
1485 self.removeCheckoutOverlay();
1486 self.startPolling();
1487 self.bindVisibilityPolling();
1488 }
1489 }else if( event.data && event.data.type === 'paddle_checkout_closed' ){
1490 if( self.config.proxy_server_url === event.origin ) {
1491 self.removeCheckoutOverlay();
1492 window.removeEventListener('message', messageHandler);
1493 }
1494 }
1495 };
1496 window.addEventListener('message', messageHandler);
1497 setTimeout(function () {
1498 self.updateCheckoutOverlay('Checkout opened in a new tab. Please complete your payment there.');
1499 }, 1000);
1500 },
1501
1502 // ==========================================
1503 // Subscription Overlap Prompt (pre-purchase)
1504 // ==========================================
1505
1506 setLoading: function ($btn, loading) {
1507 if (!$btn || !$btn.length) return;
1508 if (loading) {
1509 $btn.addClass('gvlicense-btn-loading').prop('disabled', true);
1510 } else {
1511 $btn.removeClass('gvlicense-btn-loading').prop('disabled', false);
1512 }
1513 },
1514
1515 showToast: function (message, type) {
1516 type = type || 'info';
1517 const $toast = $('<div class="gvlicense-toast gvlicense-toast-' + type + '">' + message + '</div>');
1518 $('body').append($toast);
1519 setTimeout(function () {
1520 $toast.fadeOut(300, function () { $(this).remove(); });
1521 }, 4000);
1522 },
1523 };
1524
1525 $(document).ready(function () {
1526 // Only init on gVectors License pages
1527 if ($('.gvlicense-store').length || $('.gvlicense-account').length) {
1528 gVectorsLicense.init();
1529 }
1530 });
1531
1532 })(jQuery);
1533