| @@ -49,8 +49,44 @@ | ||
| 49 | 49 | return window.MetasyncConfig && window.MetasyncConfig.ottoName ? window.MetasyncConfig.ottoName : 'OTTO'; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | /** |
| 53 | + * Whether a Search Atlas API key is currently configured. | |
| 54 | + * | |
| 55 | + * The key is no longer rendered into an editable input — it lives encrypted | |
| 56 | + * server-side and is shown only as a masked, read-only display. Presence is | |
| 57 | + * signalled by the data-has-key attribute on that display element, so the | |
| 58 | + * cleartext key never needs to exist in the DOM for the UI to know it is set. | |
| 59 | + * | |
| 60 | + * @returns {boolean} | |
| 61 | + */ | |
| 62 | + function searchAtlasKeyConfigured() { | |
| 63 | + var $display = $('#searchatlas-api-key-display'); | |
| 64 | + if ($display.length === 0) { | |
| 65 | + return false; | |
| 66 | + } | |
| 67 | + return $display.attr('data-has-key') === '1'; | |
| 68 | + } | |
| 69 | + | |
| 70 | + /** | |
| 71 | + * Update the read-only masked API key display. | |
| 72 | + * | |
| 73 | + * @param {string} maskedKey Server-provided masked value (e.g. "••••••••ab12"), | |
| 74 | + * or falsy to render the "Not configured" state. | |
| 75 | + */ | |
| 76 | + function setSearchAtlasMaskedDisplay(maskedKey) { | |
| 77 | + var $display = $('#searchatlas-api-key-display'); | |
| 78 | + if ($display.length === 0) { | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + if (maskedKey) { | |
| 82 | + $display.text(maskedKey).attr('data-has-key', '1'); | |
| 83 | + } else { | |
| 84 | + $display.html('<em style="color:#646970;">Not configured</em>').attr('data-has-key', '0'); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 53 | 89 | * Update integration status indicator in header |
| 54 | 90 | * @param {boolean} isIntegrated - Whether the integration is active |
| 55 | 91 | * @param {string} statusText - Status text to display |
| 56 | 92 | * @param {string} titleText - Tooltip text |
| @@ -235,9 +271,10 @@ | ||
| 235 | 271 | |
| 236 | 272 | function metasyncLGLogin(user, pass) { |
| 237 | 273 | jQuery.post(ajaxurl, { |
| 238 | 274 | action: 'metasync_lglogin', |
| 239 | - username: user, password: pass | |
| 275 | + username: user, password: pass, | |
| 276 | + nonce: metaSync.lglogin_nonce || '' | |
| 240 | 277 | }, function (response) { |
| 241 | 278 | if (typeof response.token !== 'undefined') { |
| 242 | 279 | $('#linkgraph_token').val(response.token); |
| 243 | 280 | $('#linkgraph_customer_id').val(response.customer_id); |
| @@ -470,9 +507,9 @@ | ||
| 470 | 507 | |
| 471 | 508 | function resetConnectButton() { |
| 472 | 509 | var $button = $('#connect-searchatlas-btn'); |
| 473 | 510 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 474 | - var hasApiKey = $('#searchatlas-api-key').val().trim() !== ''; | |
| 511 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 475 | 512 | |
| 476 | 513 | $button.prop('disabled', false) |
| 477 | 514 | .removeClass('connecting authenticating success dashboard-loading') // Remove all loading classes |
| 478 | 515 | .html(hasApiKey ? '🔄 Re-authenticate with ' + getPluginName() : '🔗 Connect to ' + getPluginName()); |
| @@ -480,9 +517,14 @@ | ||
| 480 | 517 | } |
| 481 | 518 | |
| 482 | 519 | function startSearchAtlasPolling(nonceToken) { |
| 483 | 520 | var pollCount = 0; |
| 484 | - var maxPolls = 12; // Poll for 60 seconds (12 * 5 seconds) | |
| 521 | + // WP-558 (Option A): poll for 2 minutes (24 * 5s). Each poll also drives | |
| 522 | + // the outbound pull-based connect fallback server-side, which needs a | |
| 523 | + // slightly longer window than the legacy push-only 60s to ride out the | |
| 524 | + // human SSO-authorization delay. ~24 requests stays inside the CA | |
| 525 | + // endpoint's 120/hour per-domain budget. | |
| 526 | + var maxPolls = 24; // Poll for 120 seconds (24 * 5 seconds) | |
| 485 | 527 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 486 | 528 | var $progressFill = $('.metasync-sa-connect-progress-fill'); |
| 487 | 529 | var $progressText = $('.metasync-sa-connect-progress-text'); |
| 488 | 530 | var $button = $('#connect-searchatlas-btn'); |
| @@ -521,20 +563,20 @@ | ||
| 521 | 563 | |
| 522 | 564 | // ✅ Reset the authentication flow while keeping the timeout component |
| 523 | 565 | resetConnectButton(); |
| 524 | 566 | |
| 525 | - showConnectError('⏰ Authentication Timeout', | |
| 526 | - 'The authentication process timed out after 60 seconds. Please complete the authentication more quickly or check for network issues.', | |
| 567 | + showConnectError('⏰ Authentication Timeout', | |
| 568 | + 'The authentication process timed out after 2 minutes. Please complete the authentication more quickly or check for network issues.', | |
| 527 | 569 | [{ |
| 528 | 570 | text: '🔄 Try Again', |
| 529 | 571 | action: function () { |
| 530 | - handleSearchAtlasConnect(); | |
| 572 | + handleSearchAtlasConnect(); | |
| 531 | 573 | } |
| 532 | 574 | }, { |
| 533 | 575 | text: '💬 Contact Support', |
| 534 | - action: function () { | |
| 576 | + action: function () { | |
| 535 | 577 | var supportEmail = metaSync.support_email || '[email protected]'; |
| 536 | - window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (30s)', '_blank'); | |
| 578 | + window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (120s)', '_blank'); | |
| 537 | 579 | } |
| 538 | 580 | }] |
| 539 | 581 | ); |
| 540 | 582 | return; |
| @@ -569,9 +611,9 @@ | ||
| 569 | 611 | |
| 570 | 612 | // Handle different status codes with enhanced UX |
| 571 | 613 | if (statusCode === 200) { |
| 572 | 614 | // Success: Update all UI elements to reflect connected state |
| 573 | - updateUIForConnectedState(response.data.api_key, response.data.otto_pixel_uuid); | |
| 615 | + updateUIForConnectedState(response.data.masked_key, response.data.otto_pixel_uuid); | |
| 574 | 616 | |
| 575 | 617 | // Refresh Plugin Auth Token field to show the auto-generated token |
| 576 | 618 | // This will either show the existing token or the newly auto-generated one |
| 577 | 619 | $.ajax({ |
| @@ -602,9 +644,9 @@ | ||
| 602 | 644 | $button.closest('.metasync-sa-connect-container').removeClass('metasync-sa-connect-success-animation'); |
| 603 | 645 | }, 600); |
| 604 | 646 | |
| 605 | 647 | // Track 1-click activation in GA4 |
| 606 | - var hasExistingApiKey = $('#searchatlas-api-key').val() && $('#searchatlas-api-key').val().trim() !== ''; | |
| 648 | + var hasExistingApiKey = searchAtlasKeyConfigured(); | |
| 607 | 649 | $.ajax({ |
| 608 | 650 | url: metaSync.ajax_url, |
| 609 | 651 | type: 'POST', |
| 610 | 652 | data: { |
| @@ -737,9 +779,9 @@ | ||
| 737 | 779 | var timeElapsed = currentPoll * 5; |
| 738 | 780 | var timeRemaining = (maxPolls - currentPoll) * 5; |
| 739 | 781 | $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining'); |
| 740 | 782 | |
| 741 | - // Update progress text based on time elapsed (optimized for 60-second timeout) | |
| 783 | + // Update progress text based on time elapsed (optimized for 120-second timeout) | |
| 742 | 784 | var progressMessages = [ |
| 743 | 785 | 'Establishing connection and opening authentication window...', |
| 744 | 786 | 'Please complete authentication in the popup window...', |
| 745 | 787 | 'Almost done! Finalizing your authentication...' |
| @@ -879,9 +921,18 @@ | ||
| 879 | 921 | }, { |
| 880 | 922 | text: '📚 Learn More About Registration', |
| 881 | 923 | action: function () { |
| 882 | 924 | var docDomain = metaSync.documentation_domain || 'https://searchatlas.com'; |
| 883 | - window.open(docDomain, '_blank'); | |
| 925 | + try { | |
| 926 | + var parsedDoc = new URL(docDomain); | |
| 927 | + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') { | |
| 928 | + var a = document.createElement('a'); | |
| 929 | + a.href = parsedDoc.href; | |
| 930 | + a.target = '_blank'; | |
| 931 | + a.rel = 'noopener'; | |
| 932 | + a.click(); | |
| 933 | + } | |
| 934 | + } catch (e) { /* invalid URL */ } | |
| 884 | 935 | } |
| 885 | 936 | }, { |
| 886 | 937 | text: '🔄 Try Authentication Again', |
| 887 | 938 | action: function () { |
| @@ -963,9 +1014,9 @@ | ||
| 963 | 1014 | 'Check if firewall is blocking the request' |
| 964 | 1015 | ]; |
| 965 | 1016 | } else if (context === 'timeout') { |
| 966 | 1017 | recoverySuggestions = [ |
| 967 | - 'Complete authentication within 60 seconds', | |
| 1018 | + 'Complete authentication within 2 minutes', | |
| 968 | 1019 | 'Check if the popup window needs attention', |
| 969 | 1020 | 'Ensure you have your ' + getPluginName() + ' login ready', |
| 970 | 1021 | 'Try the authentication process again', |
| 971 | 1022 | 'Contact support if timeouts persist' |
| @@ -1095,17 +1146,17 @@ | ||
| 1095 | 1146 | |
| 1096 | 1147 | // Add connection status indicator |
| 1097 | 1148 | function updateConnectionStatus() { |
| 1098 | 1149 | var $button = $('#connect-searchatlas-btn'); |
| 1099 | - var $apiKeyField = $('#searchatlas-api-key'); | |
| 1100 | - | |
| 1101 | - // Only update status if we're on a page with the API key field (General Settings) | |
| 1150 | + var $apiKeyField = $('#searchatlas-api-key-display'); | |
| 1151 | + | |
| 1152 | + // Only update status if we're on a page with the API key display (General Settings) | |
| 1102 | 1153 | // On other pages, preserve the PHP-determined status in the header |
| 1103 | 1154 | if ($apiKeyField.length === 0) { |
| 1104 | - return; // Don't update status on pages without the API key field | |
| 1155 | + return; // Don't update status on pages without the API key display | |
| 1105 | 1156 | } |
| 1106 | - | |
| 1107 | - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== ''; | |
| 1157 | + | |
| 1158 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1108 | 1159 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 1109 | 1160 | var isFullyConnected = hasApiKey && hasOttoUuid; |
| 1110 | 1161 | |
| 1111 | 1162 | // Update button text based on connection state |
| @@ -1118,11 +1169,24 @@ | ||
| 1118 | 1169 | $button.html('🔗 Connect to ' + getPluginName()); |
| 1119 | 1170 | } |
| 1120 | 1171 | } |
| 1121 | 1172 | |
| 1122 | - // Update header status indicator (only on General Settings page) | |
| 1123 | - if (isFullyConnected) { | |
| 1173 | + // Update header status indicator (only on General Settings page). | |
| 1174 | + // the header badge must reflect the confirmed heartbeat | |
| 1175 | + // round-trip (metaSync.is_connected), NOT mere API-key/UUID presence — | |
| 1176 | + // otherwise a revoked/invalid key still shows "Connected" because the | |
| 1177 | + // key string and UUID remain stored locally. | |
| 1178 | + // NOTE: wp_localize_script stringifies PHP booleans (true -> "1", | |
| 1179 | + // false -> ""), so normalise to a real boolean rather than ===. | |
| 1180 | + var heartbeatConnected = metaSync.is_connected === true | |
| 1181 | + || metaSync.is_connected === '1' | |
| 1182 | + || metaSync.is_connected === 1; | |
| 1183 | + if (isFullyConnected && heartbeatConnected) { | |
| 1124 | 1184 | updateHeaderStatus(true, 'Synced', getPluginName() + ' API key and ' + getOttoName() + ' UUID are configured'); |
| 1185 | + } else if (isFullyConnected) { | |
| 1186 | + // Key + UUID are configured but the heartbeat is not succeeding | |
| 1187 | + // (e.g. the key was revoked on the Search Atlas side). | |
| 1188 | + updateHeaderStatus(false, 'Not Connected', getPluginName() + ' API key is set but the heartbeat is not responding — the connection may be revoked or unreachable.'); | |
| 1125 | 1189 | } else if (hasApiKey && !hasOttoUuid) { |
| 1126 | 1190 | updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.'); |
| 1127 | 1191 | } else { |
| 1128 | 1192 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| @@ -1128,11 +1192,11 @@ | ||
| 1128 | 1192 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| 1129 | 1193 | } |
| 1130 | 1194 | } |
| 1131 | 1195 | |
| 1132 | - // Monitor API key field changes | |
| 1133 | - $('#searchatlas-api-key').on('input', updateConnectionStatus); | |
| 1134 | - | |
| 1196 | + // The API key is no longer manually editable (one-click auth only), so there | |
| 1197 | + // is no input event to monitor; status changes are driven by connect/disconnect. | |
| 1198 | + | |
| 1135 | 1199 | // Initial status update |
| 1136 | 1200 | updateConnectionStatus(); |
| 1137 | 1201 | |
| 1138 | 1202 | // Initialize dashboard iframe functionality |
| @@ -1141,13 +1205,12 @@ | ||
| 1141 | 1205 | // Settings dropdown now handled by inline script in HTML |
| 1142 | 1206 | |
| 1143 | 1207 | // Add debug function for connection status (accessible in console) |
| 1144 | 1208 | window.debugConnectionStatus = function () { |
| 1145 | - var apiKey = $('#searchatlas-api-key').val(); | |
| 1146 | - var hasApiKey = apiKey.trim() !== ''; | |
| 1147 | - | |
| 1209 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1210 | + | |
| 1148 | 1211 | console.log('🔍 Connection Status Debug:', { |
| 1149 | - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY', | |
| 1212 | + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY', | |
| 1150 | 1213 | otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET', |
| 1151 | 1214 | connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' : |
| 1152 | 1215 | hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED', |
| 1153 | 1216 | dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO', |
| @@ -1269,9 +1332,8 @@ | ||
| 1269 | 1332 | } |
| 1270 | 1333 | |
| 1271 | 1334 | var $resetButton = $('#reset-searchatlas-auth'); |
| 1272 | 1335 | var $connectButton = $('#connect-searchatlas-btn'); |
| 1273 | - var $apiKeyField = $('#searchatlas-api-key'); | |
| 1274 | 1336 | |
| 1275 | 1337 | // Show loading state (prevent dashboard.js conflicts) |
| 1276 | 1338 | $resetButton.prop('disabled', true) |
| 1277 | 1339 | .addClass('no-loading') // Prevent dashboard.js interference |
| @@ -1290,11 +1352,11 @@ | ||
| 1290 | 1352 | nonce: metaSync.reset_auth_nonce |
| 1291 | 1353 | }, |
| 1292 | 1354 | success: function (response) { |
| 1293 | 1355 | if (response.success) { |
| 1294 | - // Clear the API key field | |
| 1295 | - $apiKeyField.val(''); | |
| 1296 | - | |
| 1356 | + // Reset the masked display to the "Not configured" state | |
| 1357 | + setSearchAtlasMaskedDisplay(''); | |
| 1358 | + | |
| 1297 | 1359 | // Update button states |
| 1298 | 1360 | $connectButton.html('🔗 Connect to ' + getPluginName()); |
| 1299 | 1361 | $resetButton.remove(); // Remove reset button since no longer connected |
| 1300 | 1362 | |
| @@ -1389,10 +1451,18 @@ | ||
| 1389 | 1451 | /** |
| 1390 | 1452 | * Update UI elements when account is disconnected |
| 1391 | 1453 | */ |
| 1392 | 1454 | function updateUIForDisconnectedState() { |
| 1455 | + // a cached manual-sync cooldown must not survive disconnect — | |
| 1456 | + // it keeps the Sync Now button locked after the user reconnects even | |
| 1457 | + // though the server-side cooldown stamp was cleared with the auth data. | |
| 1458 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 1459 | + refreshManualSyncCooldownUI(); | |
| 1460 | + $('#sendAuthToken').prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 1461 | + | |
| 1393 | 1462 | // Update API key field placeholder |
| 1394 | - $('#searchatlas-api-key').attr('placeholder', 'Your API key will appear here after authentication'); | |
| 1463 | + // Reset the masked API key display to the "Not configured" state | |
| 1464 | + setSearchAtlasMaskedDisplay(''); | |
| 1395 | 1465 | |
| 1396 | 1466 | // ✅ Clear OTTO Pixel UUID field |
| 1397 | 1467 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(''); |
| 1398 | 1468 | |
| @@ -1442,14 +1512,13 @@ | ||
| 1442 | 1512 | /** |
| 1443 | 1513 | * Update UI elements when account is connected/authenticated |
| 1444 | 1514 | * Complementary function to updateUIForDisconnectedState() |
| 1445 | 1515 | */ |
| 1446 | - function updateUIForConnectedState(apiKey, ottoPixelUuid) { | |
| 1447 | - // Update API key field | |
| 1448 | - if (apiKey) { | |
| 1449 | - $('#searchatlas-api-key').val(apiKey); | |
| 1450 | - } | |
| 1451 | - | |
| 1516 | + function updateUIForConnectedState(maskedKey, ottoPixelUuid) { | |
| 1517 | + // Update the read-only masked API key display. Only the masked value is | |
| 1518 | + // ever shown — the cleartext key is never rendered into the DOM. | |
| 1519 | + setSearchAtlasMaskedDisplay(maskedKey || '••••••••'); | |
| 1520 | + | |
| 1452 | 1521 | // ✅ Update OTTO Pixel UUID field |
| 1453 | 1522 | if (ottoPixelUuid) { |
| 1454 | 1523 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid); |
| 1455 | 1524 | } |
| @@ -1853,8 +1922,9 @@ | ||
| 1853 | 1922 | method: 'POST', |
| 1854 | 1923 | url: 'admin-ajax.php', |
| 1855 | 1924 | data: { |
| 1856 | 1925 | action: 'metasync_send_giapi', |
| 1926 | + nonce: metaSync.nonce, | |
| 1857 | 1927 | metasync_giapi_url: url.val(), |
| 1858 | 1928 | metasync_giapi_action: action.val() |
| 1859 | 1929 | } |
| 1860 | 1930 | }) |
| @@ -1995,17 +2065,18 @@ | ||
| 1995 | 2065 | if (showAlerts) { |
| 1996 | 2066 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| 1997 | 2067 | } |
| 1998 | 2068 | |
| 1999 | - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') { | |
| 2000 | - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 2001 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2069 | + if ($('#searchatlas-api-key-display').length > 0 && !searchAtlasKeyConfigured()) { | |
| 2070 | + if (!is_hb) { | |
| 2071 | + $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 2072 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2002 | 2073 | |
| 2003 | - // Remove stale PHP-rendered "✓ Synced" label. | |
| 2004 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2074 | + // Remove stale PHP-rendered "✓ Synced" label. | |
| 2075 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2005 | 2076 | |
| 2006 | - // Update header status to "Not Synced" for missing API key | |
| 2007 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2077 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2078 | + } | |
| 2008 | 2079 | |
| 2009 | 2080 | if (showAlerts) { |
| 2010 | 2081 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2011 | 2082 | } |
| @@ -2010,34 +2081,23 @@ | ||
| 2010 | 2081 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2011 | 2082 | } |
| 2012 | 2083 | |
| 2013 | 2084 | } else if (response && response.throttled) { |
| 2014 | - // Handle throttling response | |
| 2015 | - var remainingMinutes = response.remaining_minutes || 5; | |
| 2016 | - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again'); | |
| 2017 | - //$('#sendAuthTokenTimestamp').css({ color: 'orange' }); | |
| 2018 | - | |
| 2019 | - // Disable sync button and show countdown | |
| 2085 | + // Compute expiry locally from a duration (remaining_seconds) so server/client | |
| 2086 | + // clock drift cannot lock the user out or let them bypass the throttle. | |
| 2087 | + var remainingSeconds = (typeof response.remaining_seconds === 'number') | |
| 2088 | + ? response.remaining_seconds | |
| 2089 | + : ((response.remaining_minutes || 5) * 60); | |
| 2090 | + var expiryMs = Date.now() + (remainingSeconds * 1000); | |
| 2091 | + var remainingMinutes = Math.max(1, Math.ceil(remainingSeconds / 60)); | |
| 2092 | + | |
| 2020 | 2093 | if (showAlerts) { |
| 2021 | - $('#sendAuthToken').prop('disabled', true).text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2022 | - | |
| 2023 | - // Start countdown timer | |
| 2024 | - var countdownInterval = setInterval(function () { | |
| 2025 | - remainingMinutes--; | |
| 2026 | - if (remainingMinutes <= 0) { | |
| 2027 | - clearInterval(countdownInterval); | |
| 2028 | - $('#sendAuthToken').prop('disabled', false).text('🔄 Sync Now'); | |
| 2029 | - $('#sendAuthTokenTimestamp').text('Ready to sync'); | |
| 2030 | - $('#sendAuthTokenTimestamp').css({ color: 'green' }); | |
| 2031 | - } else { | |
| 2032 | - $('#sendAuthToken').text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2033 | - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again'); | |
| 2034 | - } | |
| 2035 | - }, 60000); // Update every minute | |
| 2094 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs)); | |
| 2095 | + refreshManualSyncCooldownUI(); | |
| 2036 | 2096 | } |
| 2037 | 2097 | |
| 2038 | - // Update header status to show throttling | |
| 2039 | - updateHeaderStatus(false, 'Throttled', 'Throttled - Please wait ' + remainingMinutes + ' minutes'); | |
| 2098 | + // Throttle is transient — don't change the connection badge. | |
| 2099 | + // The toast notification already informs the user. | |
| 2040 | 2100 | |
| 2041 | 2101 | if (showAlerts) { |
| 2042 | 2102 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2043 | 2103 | } |
| @@ -2042,17 +2102,19 @@ | ||
| 2042 | 2102 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2043 | 2103 | } |
| 2044 | 2104 | |
| 2045 | 2105 | } else if (response && response.detail) { |
| 2046 | - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2047 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2106 | + if (!is_hb) { | |
| 2107 | + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2108 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2048 | 2109 | |
| 2049 | - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2050 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2110 | + // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2111 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2112 | + } | |
| 2051 | 2113 | |
| 2052 | - // Update header status to "Not Synced" for invalid API key | |
| 2114 | + // No is_hb guard here — genuinely invalid keys should always flip the badge | |
| 2053 | 2115 | updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key'); |
| 2054 | - | |
| 2116 | + | |
| 2055 | 2117 | if (showAlerts) { |
| 2056 | 2118 | showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.'); |
| 2057 | 2119 | } |
| 2058 | 2120 | |
| @@ -2057,9 +2119,11 @@ | ||
| 2057 | 2119 | } |
| 2058 | 2120 | |
| 2059 | 2121 | } else if (response === null || !response.id) { |
| 2060 | 2122 | // Update header status to "Not Synced" after failed sync |
| 2061 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2123 | + if (!is_hb) { | |
| 2124 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2125 | + } | |
| 2062 | 2126 | |
| 2063 | 2127 | if (showAlerts) { |
| 2064 | 2128 | showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.'); |
| 2065 | 2129 | } |
| @@ -2068,8 +2132,16 @@ | ||
| 2068 | 2132 | } else { |
| 2069 | 2133 | var dateString = dateFormat(); |
| 2070 | 2134 | $('#sendAuthTokenTimestamp').html(dateString); |
| 2071 | 2135 | $('#sendAuthTokenTimestamp').css({ color: 'green' }); |
| 2136 | + // Server starts a 5-minute manual-sync cooldown after a successful sync. | |
| 2137 | + // Reflect that on the client so the button shows the cooldown across refreshes | |
| 2138 | + // instead of misleadingly saying "Sync Now" only to be rejected on next click. | |
| 2139 | + if (!is_hb) { | |
| 2140 | + var successCooldownMs = 5 * 60 * 1000; | |
| 2141 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(Date.now() + successCooldownMs)); | |
| 2142 | + refreshManualSyncCooldownUI(); | |
| 2143 | + } | |
| 2072 | 2144 | |
| 2073 | 2145 | // Update header status — check if UUID is present before declaring fully synced |
| 2074 | 2146 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 2075 | 2147 | if (hasOttoUuid) { |
| @@ -2084,9 +2156,11 @@ | ||
| 2084 | 2156 | } |
| 2085 | 2157 | }, |
| 2086 | 2158 | error: function (xhr, status, error) { |
| 2087 | 2159 | // Update header status to "Not Synced" for network errors |
| 2088 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2160 | + if (!is_hb) { | |
| 2161 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2162 | + } | |
| 2089 | 2163 | |
| 2090 | 2164 | // Reset button state |
| 2091 | 2165 | if (showAlerts) { |
| 2092 | 2166 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| @@ -2115,9 +2189,10 @@ | ||
| 2115 | 2189 | url: ajaxurl, |
| 2116 | 2190 | type: 'GET', |
| 2117 | 2191 | data: { |
| 2118 | 2192 | action: 'metasync_clear_otto_cache', |
| 2119 | - clear_otto_cache: 1 | |
| 2193 | + clear_otto_cache: 1, | |
| 2194 | + nonce: metaSync.clear_otto_cache_nonce || '' | |
| 2120 | 2195 | }, |
| 2121 | 2196 | success: function (response) { |
| 2122 | 2197 | const now = new Date(); |
| 2123 | 2198 | $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString()); |
| @@ -2125,8 +2200,65 @@ | ||
| 2125 | 2200 | } |
| 2126 | 2201 | }); |
| 2127 | 2202 | } |
| 2128 | 2203 | |
| 2204 | + // Holds the active manual-sync countdown interval so we can replace it | |
| 2205 | + // instead of stacking multiple timers if refreshManualSyncCooldownUI() | |
| 2206 | + // is called more than once (e.g. throttled response then refresh). | |
| 2207 | + var _manualSyncCountdownInterval = null; | |
| 2208 | + | |
| 2209 | + /** | |
| 2210 | + * Read the manual-sync throttle expiry (client-time ms) from localStorage | |
| 2211 | + * and apply UI state: disable the button, show "Throttled (Nm)", and tick | |
| 2212 | + * every 15s. Idempotent — safe to call repeatedly. | |
| 2213 | + */ | |
| 2214 | + function refreshManualSyncCooldownUI() { | |
| 2215 | + var $btn = $('#sendAuthToken'); | |
| 2216 | + if ($btn.length === 0) { | |
| 2217 | + return; | |
| 2218 | + } | |
| 2219 | + if (_manualSyncCountdownInterval) { | |
| 2220 | + clearInterval(_manualSyncCountdownInterval); | |
| 2221 | + _manualSyncCountdownInterval = null; | |
| 2222 | + } | |
| 2223 | + var stored = localStorage.getItem('metasync_manual_sync_throttle_expires'); | |
| 2224 | + if (!stored) { | |
| 2225 | + return; | |
| 2226 | + } | |
| 2227 | + var storedExpiry = parseInt(stored, 10); | |
| 2228 | + if (!storedExpiry || isNaN(storedExpiry)) { | |
| 2229 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2230 | + return; | |
| 2231 | + } | |
| 2232 | + var nowMs = Date.now(); | |
| 2233 | + if (storedExpiry <= nowMs) { | |
| 2234 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2235 | + return; | |
| 2236 | + } | |
| 2237 | + var remainingMinutes = Math.max(1, Math.ceil((storedExpiry - nowMs) / 60000)); | |
| 2238 | + // addClass('is-throttled') drives the visual state via CSS so it | |
| 2239 | + // doesn't depend on the disabled attribute being preserved by other | |
| 2240 | + // code paths in the AJAX callback chain (which strip it on success). | |
| 2241 | + $btn.prop('disabled', true).addClass('is-throttled').text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2242 | + _manualSyncCountdownInterval = setInterval(function () { | |
| 2243 | + var currentNowMs = Date.now(); | |
| 2244 | + if (storedExpiry <= currentNowMs) { | |
| 2245 | + clearInterval(_manualSyncCountdownInterval); | |
| 2246 | + _manualSyncCountdownInterval = null; | |
| 2247 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2248 | + $btn.prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 2249 | + $('#sendAuthTokenTimestamp').text('Ready to sync').css({ color: 'green' }); | |
| 2250 | + // Cooldown is over — clear the lingering "Request Throttled" admin | |
| 2251 | + // notice at the top of the page. Without this the button resets to | |
| 2252 | + // "Sync Now" but the red throttled bar stays visible (QA). | |
| 2253 | + $('.metasync-sync-notice, .metasync-sync-error').remove(); | |
| 2254 | + return; | |
| 2255 | + } | |
| 2256 | + remainingMinutes = Math.max(1, Math.ceil((storedExpiry - currentNowMs) / 60000)); | |
| 2257 | + $btn.text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2258 | + }, 15000); | |
| 2259 | + } | |
| 2260 | + | |
| 2129 | 2261 | jQuery(document).ready(function () { |
| 2130 | 2262 | |
| 2131 | 2263 | // sendCustomerParams(); |
| 2132 | 2264 | $('#sendAuthToken').on('click', function (e) { |
| @@ -2131,11 +2263,14 @@ | ||
| 2131 | 2263 | // sendCustomerParams(); |
| 2132 | 2264 | $('#sendAuthToken').on('click', function (e) { |
| 2133 | 2265 | e.preventDefault(); |
| 2134 | 2266 | sendCustomerParams(); |
| 2135 | - | |
| 2267 | + | |
| 2136 | 2268 | }); |
| 2137 | 2269 | |
| 2270 | + // Restore manual-sync throttle countdown state across page refreshes. | |
| 2271 | + refreshManualSyncCooldownUI(); | |
| 2272 | + | |
| 2138 | 2273 | // handle otto clear cache button |
| 2139 | 2274 | $('#clear_otto_caches').on('click', function (e){ |
| 2140 | 2275 | e.preventDefault(); |
| 2141 | 2276 | clear_otto_caches(); |
| @@ -2190,18 +2325,39 @@ | ||
| 2190 | 2325 | data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab |
| 2191 | 2326 | success: function (response) { |
| 2192 | 2327 | // Handle success response |
| 2193 | 2328 | if(response.success){ |
| 2329 | + // Per-field whitelabel validation issues: the save succeeded for | |
| 2330 | + // other fields, so show a warning notice and stay on the page so the | |
| 2331 | + // user can see which values were rejected. | |
| 2332 | + const wlErrors = response.data && response.data.whitelabel_errors; | |
| 2333 | + if (wlErrors && Object.keys(wlErrors).length > 0) { | |
| 2334 | + var $warnNotice = $('<div>').addClass('notice notice-warning metasync-error-wrap'); | |
| 2335 | + var $warnList = $('<ul>'); | |
| 2336 | + Object.keys(wlErrors).forEach(function (fieldKey) { | |
| 2337 | + $warnList.append($('<li>').text(wlErrors[fieldKey])); | |
| 2338 | + }); | |
| 2339 | + $warnNotice.append($('<p>').text('Settings saved, but some values were not applied:')); | |
| 2340 | + $warnNotice.append($warnList); | |
| 2341 | + $('.metasync-error-wrap').remove(); | |
| 2342 | + $('#metaSyncGeneralSetting').before($warnNotice); | |
| 2343 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2344 | + return; | |
| 2345 | + } | |
| 2346 | + | |
| 2194 | 2347 | // get value of input field white_label_plugin_menu_slug |
| 2195 | 2348 | const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val(); |
| 2196 | 2349 | // check condition if it is empty or not and redirect it |
| 2197 | - | |
| 2350 | + | |
| 2198 | 2351 | // add the tag query to the window location |
| 2199 | 2352 | let tabParam = new URLSearchParams(window.location.search).get('tab'); |
| 2200 | 2353 | let tabQuery = tabParam ? '&tab=' + encodeURIComponent(tabParam) : ''; |
| 2201 | - | |
| 2202 | - // Handle undefined or empty white label URL — sanitize to valid slug characters only | |
| 2203 | - const rawSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas'; | |
| 2354 | + | |
| 2355 | + // Prefer the server-sanitized slug so the redirect matches the page | |
| 2356 | + // WordPress actually registered; fall back to the raw input field value. | |
| 2357 | + const savedSlug = response.data && typeof response.data.saved_menu_slug === 'string' ? response.data.saved_menu_slug : null; | |
| 2358 | + const inputSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas'; | |
| 2359 | + const rawSlug = (savedSlug !== null) ? (savedSlug || 'searchatlas') : inputSlug; | |
| 2204 | 2360 | const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas'; |
| 2205 | 2361 | var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery; |
| 2206 | 2362 | try { |
| 2207 | 2363 | var parsedRedirect = new URL(redirectUrl, window.location.origin); |
| @@ -2494,11 +2650,8 @@ | ||
| 2494 | 2650 | var currentTab = urlParams.get('tab') || 'general'; |
| 2495 | 2651 | |
| 2496 | 2652 | formData += '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab); |
| 2497 | 2653 | |
| 2498 | - // Capture the current API key value so we can restore it client-side if validation fails | |
| 2499 | - var savedApiKeyValue = $('#searchatlas-api-key').val(); | |
| 2500 | - | |
| 2501 | 2654 | $.ajax({ |
| 2502 | 2655 | url: metaSync.ajax_url, |
| 2503 | 2656 | type: 'POST', |
| 2504 | 2657 | data: formData, |
| @@ -2520,10 +2673,17 @@ | ||
| 2520 | 2673 | } else if (response.data && response.data.api_key_validated === false) { |
| 2521 | 2674 | noticeType = 'notice-error'; |
| 2522 | 2675 | noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html(); |
| 2523 | 2676 | noAutoDismiss = true; |
| 2524 | - // Revert the input field to the value before save attempt | |
| 2525 | - $('#searchatlas-api-key').val(savedApiKeyValue); | |
| 2677 | + // The server reverts the stored key to the last valid one on | |
| 2678 | + // verification failure, and the masked display is read-only, so | |
| 2679 | + // nothing in the DOM needs reverting here. The cleartext key is | |
| 2680 | + // never sent to the browser. | |
| 2681 | + } else if (response.data && response.data.api_key_removed === true) { | |
| 2682 | + // API key was cleared — show warning, update status, page will reload | |
| 2683 | + noticeType = 'notice-warning'; | |
| 2684 | + noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html(); | |
| 2685 | + updateHeaderStatus(false, 'Disconnected', getPluginName() + ' disconnected'); | |
| 2526 | 2686 | } else if (response.data && response.data.warning) { |
| 2527 | 2687 | // Network failure — key saved but unverified |
| 2528 | 2688 | noticeType = 'notice-warning'; |
| 2529 | 2689 | noticeMessage = $('<span/>').text(response.data.warning).html(); |
| @@ -2545,11 +2705,11 @@ | ||
| 2545 | 2705 | |
| 2546 | 2706 | // Scroll to the notice for better visibility |
| 2547 | 2707 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2548 | 2708 | |
| 2549 | - // Reload page when API key was validated so server-rendered | |
| 2709 | + // Reload page when API key status changed so server-rendered | |
| 2550 | 2710 | // sections (One-Click Authentication, promo sidebar) update |
| 2551 | - if (response.data && response.data.api_key_validated === true) { | |
| 2711 | + if (response.data && (response.data.api_key_validated === true || response.data.api_key_removed === true)) { | |
| 2552 | 2712 | setTimeout(function () { |
| 2553 | 2713 | location.reload(); |
| 2554 | 2714 | }, 1500); |
| 2555 | 2715 | return; |
| @@ -3120,26 +3280,65 @@ | ||
| 3120 | 3280 | * @param {jQuery} $trigger - The trigger button |
| 3121 | 3281 | * @param {jQuery} $tooltip - The tooltip element |
| 3122 | 3282 | */ |
| 3123 | 3283 | function positionTooltip($trigger, $tooltip) { |
| 3124 | - // Skip positioning on mobile (uses fixed positioning) | |
| 3284 | + // Mobile uses the CSS fixed bottom-sheet layout; clear any inline desktop | |
| 3285 | + // styles so the stylesheet takes over. | |
| 3125 | 3286 | if (window.innerWidth <= 768) { |
| 3287 | + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' }); | |
| 3126 | 3288 | return; |
| 3127 | 3289 | } |
| 3128 | 3290 | |
| 3129 | - var triggerRect = $trigger[0].getBoundingClientRect(); | |
| 3130 | - var tooltipWidth = $tooltip.outerWidth(); | |
| 3131 | - var viewportWidth = $(window).width(); | |
| 3132 | - var spaceRight = viewportWidth - triggerRect.right; | |
| 3291 | + // Move the tooltip up to the plugin wrapper so it escapes the | |
| 3292 | + // overflow:hidden on accordion sections / cards that was clipping it. | |
| 3293 | + // The wrapper carries data-theme (so the --dashboard-* CSS vars still | |
| 3294 | + // resolve) and is position:relative (not transformed), so the | |
| 3295 | + // position:fixed below stays relative to the viewport. Verified in a | |
| 3296 | + // headless-Chrome harness before shipping. | |
| 3297 | + var portal = document.querySelector('.metasync-dashboard-wrap') || document.body; | |
| 3298 | + if ($tooltip[0].parentNode !== portal) { | |
| 3299 | + portal.appendChild($tooltip[0]); | |
| 3300 | + } | |
| 3133 | 3301 | |
| 3134 | - // Check if tooltip would overflow on the right | |
| 3135 | - if (spaceRight < tooltipWidth + 20) { | |
| 3136 | - // Position on the left side | |
| 3137 | - $tooltip.attr('data-position', 'left'); | |
| 3138 | - } else { | |
| 3139 | - // Position on the right side (default) | |
| 3140 | - $tooltip.attr('data-position', 'right'); | |
| 3141 | - } | |
| 3302 | + var margin = 12; | |
| 3303 | + var rect = $trigger[0].getBoundingClientRect(); | |
| 3304 | + var vw = window.innerWidth; | |
| 3305 | + var vh = window.innerHeight; | |
| 3306 | + var tw = $tooltip.outerWidth(); | |
| 3307 | + var th = $tooltip.outerHeight(); | |
| 3308 | + | |
| 3309 | + // Horizontal: prefer to the right of the icon, else flip left; clamp to viewport. | |
| 3310 | + var left = rect.right + margin; | |
| 3311 | + if (left + tw + margin > vw) { left = rect.left - margin - tw; } | |
| 3312 | + if (left < margin) { left = margin; } | |
| 3313 | + if (left + tw + margin > vw) { left = Math.max(margin, vw - tw - margin); } | |
| 3314 | + | |
| 3315 | + // Vertical: center on the icon, then clamp so the top/bottom is never cut off. | |
| 3316 | + var top = rect.top + (rect.height / 2) - (th / 2); | |
| 3317 | + if (top < margin) { top = margin; } | |
| 3318 | + if (top + th + margin > vh) { top = Math.max(margin, vh - th - margin); } | |
| 3319 | + | |
| 3320 | + $tooltip.find('.metasync-tooltip-arrow').css('display', 'none'); | |
| 3321 | + // Place it, then play a small left-to-right slide-in. Position is | |
| 3322 | + // handled by left/top (fixed), so transform is free for the entrance | |
| 3323 | + // animation: start ~12px to the left and slide right into place. | |
| 3324 | + $tooltip.css({ | |
| 3325 | + position: 'fixed', | |
| 3326 | + left: Math.round(left) + 'px', | |
| 3327 | + top: Math.round(top) + 'px', | |
| 3328 | + right: 'auto', | |
| 3329 | + bottom: 'auto', | |
| 3330 | + margin: '0', | |
| 3331 | + transition: 'opacity 0.2s ease', | |
| 3332 | + transform: 'translateX(-12px)' | |
| 3333 | + }); | |
| 3334 | + // Force a reflow so the start position is committed before animating. | |
| 3335 | + void $tooltip[0].offsetWidth; | |
| 3336 | + $tooltip.css({ | |
| 3337 | + transition: 'opacity 0.2s ease, transform 0.2s ease', | |
| 3338 | + transform: 'translateX(0)' | |
| 3339 | + }); | |
| 3340 | + $tooltip.removeAttr('data-position'); | |
| 3142 | 3341 | } |
| 3143 | 3342 | |
| 3144 | 3343 | // Initialize tooltip system |
| 3145 | 3344 | initTooltipSystem(); |